1use reqwest::Client;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5use crate::constants::{
6 auth_schemes, http_headers, http_methods, introspection_fields, jwt_claims, media_types,
7};
8use crate::errors::{
9 AuthError, AuthplaneError, map_oauth_error, protocol_error, transport_error, validation_error,
10};
11use crate::fetch_settings::FetchSettings;
12use crate::transport::validate_fetch_url;
13
14pub const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
15pub const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
16
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
18pub struct TokenExchangeOptions {
19 pub subject_token: String,
20 pub subject_token_type: String,
21 pub actor_token: String,
22 pub actor_token_type: String,
23 pub scope: String,
24 pub resources: Vec<String>,
25 pub audiences: Vec<String>,
26}
27
28impl TokenExchangeOptions {
29 pub fn normalized(&self) -> Self {
30 let mut normalized = self.clone();
31 normalized.resources.retain(|value| !value.is_empty());
32 normalized.audiences.retain(|value| !value.is_empty());
33 normalized
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(from = "TokenResponseWire")]
39#[non_exhaustive]
40pub struct TokenResponse {
41 pub access_token: String,
46 pub token_type: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
71 pub expires_in: Option<i64>,
72 pub scope: String,
73 pub refresh_token: String,
74 pub issued_token_type: String,
75 pub cnf: Option<Value>,
81 pub cnf_jkt: String,
90}
91
92#[derive(Deserialize)]
100struct TokenResponseWire {
101 access_token: String,
102 token_type: String,
103 #[serde(default, deserialize_with = "deserialize_optional_expires_in")]
104 expires_in: Option<i64>,
105 scope: String,
106 #[serde(default)]
107 refresh_token: String,
108 #[serde(default)]
109 issued_token_type: String,
110 #[serde(default)]
111 cnf: Option<Value>,
112}
113
114impl From<TokenResponseWire> for TokenResponse {
115 fn from(wire: TokenResponseWire) -> Self {
116 let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
117 Self {
118 access_token: wire.access_token,
119 token_type: wire.token_type,
120 expires_in: wire.expires_in,
121 scope: wire.scope,
122 refresh_token: wire.refresh_token,
123 issued_token_type: wire.issued_token_type,
124 cnf,
125 cnf_jkt,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(from = "IntrospectionResponseWire")]
132#[non_exhaustive]
133pub struct IntrospectionResponse {
134 pub active: bool,
138 pub scope: String,
139 pub client_id: String,
140 pub sub: String,
141 pub token_type: String,
142 pub iss: String,
143 pub aud: Option<Value>,
144 pub exp: Option<i64>,
145 pub iat: Option<i64>,
146 pub jti: String,
147 pub agent_id: String,
148 pub agent_chain: Vec<String>,
149 pub cnf: Option<Value>,
156 pub cnf_jkt: String,
162}
163
164#[derive(Deserialize)]
171struct IntrospectionResponseWire {
172 active: bool,
173 #[serde(default)]
174 scope: String,
175 #[serde(default)]
176 client_id: String,
177 #[serde(default)]
178 sub: String,
179 #[serde(default)]
180 token_type: String,
181 #[serde(default)]
182 iss: String,
183 #[serde(default)]
184 aud: Option<Value>,
185 #[serde(default)]
186 exp: Option<i64>,
187 #[serde(default)]
188 iat: Option<i64>,
189 #[serde(default)]
190 jti: String,
191 #[serde(default)]
192 agent_id: String,
193 #[serde(default)]
194 agent_chain: Vec<String>,
195 #[serde(default)]
196 cnf: Option<Value>,
197}
198
199impl From<IntrospectionResponseWire> for IntrospectionResponse {
200 fn from(wire: IntrospectionResponseWire) -> Self {
201 let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
202 Self {
203 active: wire.active,
204 scope: wire.scope,
205 client_id: wire.client_id,
206 sub: wire.sub,
207 token_type: wire.token_type,
208 iss: wire.iss,
209 aud: wire.aud,
210 exp: wire.exp,
211 iat: wire.iat,
212 jti: wire.jti,
213 agent_id: wire.agent_id,
214 agent_chain: wire.agent_chain,
215 cnf,
216 cnf_jkt,
217 }
218 }
219}
220
221pub fn parse_token_exchange_error(status_code: Option<u16>, body: &str) -> AuthplaneError {
222 let parsed: Result<Value, _> = serde_json::from_str(body);
223 match parsed {
224 Ok(payload) => map_oauth_error(status_code, &payload),
225 Err(_) => AuthplaneError::Auth(AuthError {
226 message: "OAuth request failed".to_string(),
227 code: "invalid_response".to_string(),
228 status_code,
229 }),
230 }
231}
232
233pub fn parse_token_response(
240 data: &Value,
241 allow_issued_token_type: bool,
242) -> Result<TokenResponse, AuthplaneError> {
243 parse_token_response_inner(data, allow_issued_token_type, false)
244}
245
246pub fn parse_token_response_dpop(
248 data: &Value,
249 allow_issued_token_type: bool,
250) -> Result<TokenResponse, AuthplaneError> {
251 parse_token_response_inner(data, allow_issued_token_type, true)
252}
253
254fn parse_token_response_inner(
255 data: &Value,
256 allow_issued_token_type: bool,
257 expect_dpop: bool,
258) -> Result<TokenResponse, AuthplaneError> {
259 let access_token = required_string(data, "access_token")?;
260 let token_type = required_string(data, "token_type")?;
261 if !token_type.eq_ignore_ascii_case(auth_schemes::BEARER)
262 && !token_type.eq_ignore_ascii_case(auth_schemes::DPOP)
263 {
264 return Err(protocol_error(&format!(
265 "unsupported token_type {token_type:?}; only {} and {} are supported",
266 auth_schemes::BEARER,
267 auth_schemes::DPOP
268 )));
269 }
270 if expect_dpop && !token_type.eq_ignore_ascii_case(auth_schemes::DPOP) {
271 return Err(protocol_error(&format!(
272 "DPoP proof was sent but token_type is {token_type:?}, not {:?}; \
273 the authorization server may have ignored the DPoP proof (RFC 9449 §5)",
274 auth_schemes::DPOP
275 )));
276 }
277
278 let issued_token_type = optional_string(data, "issued_token_type");
279 if allow_issued_token_type {
280 if issued_token_type.is_empty() {
281 return Err(protocol_error(
282 "token exchange response missing required 'issued_token_type' (RFC 8693 §2.2.1)",
283 ));
284 }
285 if issued_token_type != TOKEN_TYPE_ACCESS_TOKEN {
286 return Err(protocol_error(&format!(
287 "unsupported issued_token_type {issued_token_type:?}; only access_token is supported"
288 )));
289 }
290 }
291
292 let (cnf, cnf_jkt) = extract_cnf_and_jkt(data);
293
294 Ok(TokenResponse {
295 access_token,
296 token_type,
297 expires_in: optional_non_negative_i64(data, "expires_in")?,
298 scope: optional_string(data, "scope"),
299 refresh_token: optional_string(data, "refresh_token"),
300 issued_token_type,
301 cnf,
302 cnf_jkt,
303 })
304}
305
306pub async fn client_credentials_grant(
307 http: &Client,
308 token_endpoint: &str,
309 auth_header: &str,
310 fetch_settings: &FetchSettings,
311 scopes: &[String],
312 resources: &[String],
313 dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
314) -> Result<TokenResponse, AuthplaneError> {
315 let form = build_client_credentials_form(scopes, resources);
316
317 let expect_dpop = dpop_provider.is_some();
318 let (status, payload) = form_post_with_dpop(
319 http,
320 token_endpoint,
321 "token endpoint",
322 &form,
323 auth_header,
324 fetch_settings,
325 dpop_provider,
326 None,
330 )
331 .await?;
332
333 if crate::transport::is_http_success(status) {
334 return if expect_dpop {
335 parse_token_response_dpop(&payload, false)
336 } else {
337 parse_token_response(&payload, false)
338 };
339 }
340
341 Err(map_oauth_error(Some(status), &payload))
342}
343
344pub async fn exchange_token(
345 http: &Client,
346 token_endpoint: &str,
347 options: &TokenExchangeOptions,
348 auth_header: &str,
349 fetch_settings: &FetchSettings,
350 dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
351) -> Result<TokenResponse, AuthplaneError> {
352 if options.subject_token.is_empty() {
353 return Err(validation_error("subject_token is required"));
354 }
355
356 let normalized = options.normalized();
357 let form = build_token_exchange_form(&normalized);
358
359 let expect_dpop = dpop_provider.is_some();
360 let (status, payload) = form_post_with_dpop(
361 http,
362 token_endpoint,
363 "token endpoint",
364 &form,
365 auth_header,
366 fetch_settings,
367 dpop_provider,
368 None,
369 )
370 .await?;
371
372 if crate::transport::is_http_success(status) {
373 return if expect_dpop {
374 parse_token_response_dpop(&payload, true)
375 } else {
376 parse_token_response(&payload, true)
377 };
378 }
379
380 Err(map_oauth_error(Some(status), &payload))
381}
382
383pub async fn introspect_token(
393 http: &Client,
394 introspection_endpoint: &str,
395 token: &str,
396 auth_header: &str,
397 fetch_settings: &FetchSettings,
398 dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
399) -> Result<IntrospectionResponse, AuthplaneError> {
400 let form = [
401 (
402 crate::constants::oauth_params::TOKEN.to_string(),
403 token.to_string(),
404 ),
405 (
406 crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
407 crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
408 ),
409 ];
410 let (status, payload) = form_post_with_dpop(
411 http,
412 introspection_endpoint,
413 "introspection endpoint",
414 &form,
415 auth_header,
416 fetch_settings,
417 dpop_provider,
418 None,
419 )
420 .await?;
421
422 if crate::transport::is_http_success(status) {
423 let (cnf, cnf_jkt) = extract_cnf_and_jkt(&payload);
424 return Ok(IntrospectionResponse {
425 active: payload
426 .get(introspection_fields::ACTIVE)
427 .and_then(Value::as_bool)
428 .unwrap_or(false),
429 scope: optional_string(&payload, introspection_fields::SCOPE),
430 client_id: optional_string(&payload, jwt_claims::CLIENT_ID),
431 sub: optional_string(&payload, jwt_claims::SUB),
432 token_type: optional_string(&payload, introspection_fields::TOKEN_TYPE),
433 iss: optional_string(&payload, jwt_claims::ISS),
434 aud: payload.get(jwt_claims::AUD).cloned(),
435 exp: payload.get(jwt_claims::EXP).and_then(Value::as_i64),
436 iat: payload.get(jwt_claims::IAT).and_then(Value::as_i64),
437 jti: optional_string(&payload, jwt_claims::JTI),
438 agent_id: optional_string(&payload, jwt_claims::AGENT_ID),
439 agent_chain: crate::json_util::string_array(&payload, jwt_claims::AGENT_CHAIN),
440 cnf,
441 cnf_jkt,
442 });
443 }
444
445 Err(map_oauth_error(Some(status), &payload))
446}
447
448pub async fn revoke_token(
449 http: &Client,
450 revocation_endpoint: &str,
451 token: &str,
452 auth_header: &str,
453 fetch_settings: &FetchSettings,
454 dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
455) -> Result<(), AuthplaneError> {
456 let form = [
457 (
458 crate::constants::oauth_params::TOKEN.to_string(),
459 token.to_string(),
460 ),
461 (
462 crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
463 crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
464 ),
465 ];
466 let (status, payload) = form_post_with_dpop(
467 http,
468 revocation_endpoint,
469 "revocation endpoint",
470 &form,
471 auth_header,
472 fetch_settings,
473 dpop_provider,
474 None,
475 )
476 .await?;
477
478 if crate::transport::is_http_success(status) {
479 return Ok(());
480 }
481
482 Err(map_oauth_error(Some(status), &payload))
483}
484
485fn required_string(data: &Value, key: &str) -> Result<String, AuthplaneError> {
486 let value = optional_string(data, key);
487 if value.is_empty() {
488 return Err(protocol_error(&format!(
489 "token response missing required field {key:?}"
490 )));
491 }
492 Ok(value)
493}
494
495fn optional_string(data: &Value, key: &str) -> String {
496 data.get(key)
497 .and_then(Value::as_str)
498 .unwrap_or_default()
499 .trim()
500 .to_string()
501}
502
503fn extract_cnf_and_jkt(payload: &Value) -> (Option<Value>, String) {
511 normalize_cnf(payload.get("cnf").cloned())
512}
513
514fn normalize_cnf(cnf: Option<Value>) -> (Option<Value>, String) {
520 let cnf = cnf.filter(Value::is_object);
521 let cnf_jkt = cnf
522 .as_ref()
523 .and_then(Value::as_object)
524 .and_then(|map| map.get("jkt"))
525 .and_then(Value::as_str)
526 .unwrap_or_default()
527 .to_string();
528 (cnf, cnf_jkt)
529}
530
531enum ParseOptionalI64Error {
541 NotAnInteger { got: Option<String> },
542 Negative,
543}
544
545fn parse_optional_non_negative_i64(value: &Value) -> Result<Option<i64>, ParseOptionalI64Error> {
555 if value.is_null() {
556 return Ok(None);
557 }
558 let parsed = if let Some(number) = value.as_i64() {
559 number
560 } else if let Some(text) = value.as_str() {
561 text.parse::<i64>()
562 .map_err(|_| ParseOptionalI64Error::NotAnInteger {
563 got: Some(text.to_string()),
564 })?
565 } else {
566 return Err(ParseOptionalI64Error::NotAnInteger { got: None });
567 };
568 if parsed < 0 {
569 return Err(ParseOptionalI64Error::Negative);
570 }
571 Ok(Some(parsed))
572}
573
574fn optional_non_negative_i64(data: &Value, key: &str) -> Result<Option<i64>, AuthplaneError> {
578 let value = data.get(key).unwrap_or(&Value::Null);
579 parse_optional_non_negative_i64(value).map_err(|err| match err {
580 ParseOptionalI64Error::NotAnInteger { got: Some(text) } => protocol_error(&format!(
581 "token response field {key:?} must be an integer, got {text:?}"
582 )),
583 ParseOptionalI64Error::NotAnInteger { got: None } => {
584 protocol_error(&format!("token response field {key:?} must be an integer"))
585 }
586 ParseOptionalI64Error::Negative => protocol_error(&format!(
587 "token response field {key:?} must be non-negative"
588 )),
589 })
590}
591
592fn deserialize_optional_expires_in<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
598where
599 D: serde::Deserializer<'de>,
600{
601 use serde::de::Error;
602 let raw: Option<Value> = Option::deserialize(deserializer)?;
603 let value = raw.unwrap_or(Value::Null);
604 parse_optional_non_negative_i64(&value).map_err(|err| match err {
605 ParseOptionalI64Error::NotAnInteger { got: Some(text) } => {
606 D::Error::custom(format!("expires_in must be an integer, got {text:?}"))
607 }
608 ParseOptionalI64Error::NotAnInteger { got: None } => {
609 D::Error::custom("expires_in must be an integer")
610 }
611 ParseOptionalI64Error::Negative => D::Error::custom("expires_in must be non-negative"),
612 })
613}
614
615#[allow(clippy::too_many_arguments)] pub(crate) async fn form_post_with_dpop(
635 http: &Client,
636 url: &str,
637 url_label: &str,
638 form: &[(String, String)],
639 auth_header: &str,
640 fetch_settings: &FetchSettings,
641 dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
642 dpop_access_token: Option<&str>,
643) -> Result<(u16, Value), AuthplaneError> {
644 validate_fetch_url(url, fetch_settings, url_label)?;
645
646 let initial_dpop = match dpop_provider {
647 Some(provider) => Some(provider.build_proof(http_methods::POST, url, dpop_access_token)?),
648 None => None,
649 };
650
651 let (status, payload, nonce) =
652 do_form_post(http, url, form, auth_header, initial_dpop.as_deref()).await?;
653
654 if let Some(provider) = dpop_provider {
656 if !nonce.is_empty() {
657 let _ = provider.note_nonce(url, &nonce);
658 }
659
660 let error_code = payload
662 .get("error")
663 .and_then(Value::as_str)
664 .unwrap_or_default();
665 if error_code == crate::constants::oauth_errors::USE_DPOP_NONCE && !nonce.is_empty() {
666 let retry_proof = provider.build_proof(http_methods::POST, url, dpop_access_token)?;
667 let (status2, payload2, nonce2) =
668 do_form_post(http, url, form, auth_header, Some(&retry_proof)).await?;
669 if !nonce2.is_empty() {
675 let _ = provider.note_nonce(url, &nonce2);
676 }
677 return Ok((status2, payload2));
678 }
679 }
680
681 Ok((status, payload))
682}
683
684async fn do_form_post(
686 http: &Client,
687 url: &str,
688 form: &[(String, String)],
689 auth_header: &str,
690 dpop_proof: Option<&str>,
691) -> Result<(u16, Value, String), AuthplaneError> {
692 let mut request = http
693 .post(url)
694 .header(http_headers::AUTHORIZATION, auth_header)
695 .header(http_headers::ACCEPT, media_types::APPLICATION_JSON);
696 if let Some(proof) = dpop_proof {
697 request = request.header(http_headers::DPOP, proof);
698 }
699 let response = request
700 .form(form)
701 .send()
702 .await
703 .map_err(|error| transport_error(&error.to_string()))?;
704 let status = response.status().as_u16();
705 let nonce = response
706 .headers()
707 .get(http_headers::DPOP_NONCE)
708 .and_then(|v| v.to_str().ok())
709 .unwrap_or_default()
710 .to_string();
711 let bytes = response
716 .bytes()
717 .await
718 .map_err(|error| transport_error(&error.to_string()))?;
719 let payload: Value = if bytes.is_empty() {
720 Value::Object(serde_json::Map::new())
721 } else {
722 serde_json::from_slice(&bytes).map_err(|error| transport_error(&error.to_string()))?
723 };
724 Ok((status, payload, nonce))
725}
726
727#[doc(hidden)]
728pub fn build_client_credentials_form(
729 scopes: &[String],
730 resources: &[String],
731) -> Vec<(String, String)> {
732 use crate::constants::oauth_params::*;
733 let mut form = vec![(
734 GRANT_TYPE.to_string(),
735 GRANT_TYPE_CLIENT_CREDENTIALS.to_string(),
736 )];
737 if !scopes.is_empty() {
738 form.push((SCOPE.to_string(), scopes.join(" ")));
739 }
740 for resource in resources.iter().filter(|value| !value.is_empty()) {
741 form.push((RESOURCE.to_string(), resource.clone()));
742 }
743 form
744}
745
746#[doc(hidden)]
747pub fn build_token_exchange_form(options: &TokenExchangeOptions) -> Vec<(String, String)> {
748 use crate::constants::oauth_params::*;
749 let mut form = vec![
750 (
751 GRANT_TYPE.to_string(),
752 GRANT_TYPE_TOKEN_EXCHANGE.to_string(),
753 ),
754 (SUBJECT_TOKEN.to_string(), options.subject_token.clone()),
755 (
756 SUBJECT_TOKEN_TYPE.to_string(),
757 if options.subject_token_type.is_empty() {
758 TOKEN_TYPE_ACCESS_TOKEN.to_string()
759 } else {
760 options.subject_token_type.clone()
761 },
762 ),
763 ];
764
765 if !options.actor_token.is_empty() {
766 form.push((ACTOR_TOKEN.to_string(), options.actor_token.clone()));
767 form.push((
768 ACTOR_TOKEN_TYPE.to_string(),
769 if options.actor_token_type.is_empty() {
770 TOKEN_TYPE_ACCESS_TOKEN.to_string()
771 } else {
772 options.actor_token_type.clone()
773 },
774 ));
775 }
776 if !options.scope.is_empty() {
777 form.push((SCOPE.to_string(), options.scope.clone()));
778 }
779 for resource in &options.resources {
780 form.push((RESOURCE.to_string(), resource.clone()));
781 }
782 for audience in &options.audiences {
783 form.push((AUDIENCE.to_string(), audience.clone()));
784 }
785 form
786}
787
788#[cfg(test)]
789mod tests {
790 use serde_json::json;
791
792 use crate::{AuthplaneError, parse_token_exchange_error};
793
794 use super::{
795 GRANT_TYPE_TOKEN_EXCHANGE, IntrospectionResponse, TOKEN_TYPE_ACCESS_TOKEN,
796 TokenExchangeOptions, TokenResponse, build_client_credentials_form,
797 build_token_exchange_form, parse_token_response,
798 };
799
800 #[test]
801 fn maps_consent_required_to_typed_error() {
802 let body = r#"{
803 "error":"consent_required",
804 "error_description":"Consent needed",
805 "service_id":"drive",
806 "cause":"approval_needed",
807 "consent_url":"https://consent.example.com/start"
808 }"#;
809
810 let error = parse_token_exchange_error(Some(400), body);
811 let AuthplaneError::ConsentRequired(consent) = error else {
812 panic!("expected consent required");
813 };
814
815 assert_eq!(consent.code, "consent_required");
816 assert_eq!(consent.service_id, "drive");
817 assert_eq!(consent.cause_detail, "approval_needed");
818 assert_eq!(
819 consent.consent_url.as_deref(),
820 Some("https://consent.example.com/start")
821 );
822 }
823
824 #[test]
825 fn maps_interaction_required_to_typed_error() {
826 let body = r#"{
827 "error":"interaction_required",
828 "error_description":"User interaction required",
829 "service":"calendar"
830 }"#;
831
832 let error = parse_token_exchange_error(Some(400), body);
833 let AuthplaneError::ConsentRequired(consent) = error else {
834 panic!("expected consent required");
835 };
836
837 assert_eq!(consent.code, "interaction_required");
838 assert_eq!(consent.service_id, "calendar");
839 assert_eq!(consent.cause_detail, "User interaction required");
840 assert_eq!(consent.consent_url, None);
841 }
842
843 #[test]
844 fn maps_invalid_json_to_invalid_response_auth_error() {
845 let error = parse_token_exchange_error(Some(500), "{invalid-json");
846 let AuthplaneError::Auth(auth_error) = error else {
847 panic!("expected auth error");
848 };
849
850 assert_eq!(auth_error.code, "invalid_response");
851 assert_eq!(auth_error.message, "OAuth request failed");
852 assert_eq!(auth_error.status_code, Some(500));
853 }
854
855 #[test]
856 fn maps_non_consent_error_to_auth_error() {
857 let body = r#"{
858 "error":"invalid_target",
859 "error_description":"target missing"
860 }"#;
861 let error = parse_token_exchange_error(Some(400), body);
862 let AuthplaneError::Auth(auth_error) = error else {
863 panic!("expected auth error");
864 };
865
866 assert_eq!(auth_error.code, "invalid_target");
867 assert_eq!(auth_error.message, "target missing");
868 assert!(auth_error.is_invalid_target());
869 assert!(!auth_error.is_access_denied());
870 assert!(!crate::should_open_circuit_for_oauth_error(
871 &auth_error.code
872 ));
873 }
874
875 #[test]
880 fn maps_access_denied_to_auth_error_outside_the_breaker() {
881 let body = r#"{
882 "error":"access_denied",
883 "error_description":"client is not allowed to exchange for this resource"
884 }"#;
885 let error = parse_token_exchange_error(Some(403), body);
886 let AuthplaneError::Auth(auth_error) = error else {
887 panic!("expected auth error");
888 };
889
890 assert_eq!(auth_error.code, "access_denied");
891 assert_eq!(auth_error.status_code, Some(403));
892 assert!(auth_error.is_access_denied());
893 assert!(!auth_error.is_invalid_target());
894 assert!(!crate::should_open_circuit_for_oauth_error(
895 &auth_error.code
896 ));
897 }
898
899 #[test]
900 fn token_exchange_options_filter_empty_resource_and_audience_values() {
901 let options = TokenExchangeOptions {
902 subject_token: "subject".to_string(),
903 resources: vec!["".to_string(), "https://api.example.com".to_string()],
904 audiences: vec!["".to_string(), "api://billing".to_string()],
905 ..TokenExchangeOptions::default()
906 }
907 .normalized();
908
909 assert_eq!(
910 options.resources,
911 vec!["https://api.example.com".to_string()]
912 );
913 assert_eq!(options.audiences, vec!["api://billing".to_string()]);
914 }
915
916 #[test]
917 fn client_credentials_form_includes_scope_and_resource() {
918 let form = build_client_credentials_form(
919 &["tools/read".to_string()],
920 &["https://api.example.com".to_string()],
921 );
922 assert!(form.contains(&("grant_type".to_string(), "client_credentials".to_string())));
923 assert!(form.contains(&("scope".to_string(), "tools/read".to_string())));
924 assert!(form.contains(&(
925 "resource".to_string(),
926 "https://api.example.com".to_string()
927 )));
928 }
929
930 #[test]
931 fn token_exchange_form_applies_defaults_and_repeated_values() {
932 let form = build_token_exchange_form(&TokenExchangeOptions {
933 subject_token: "subject-token".to_string(),
934 resources: vec![
935 "https://api-one.example.com".to_string(),
936 "https://api-two.example.com".to_string(),
937 ],
938 audiences: vec!["api://inventory".to_string()],
939 ..TokenExchangeOptions::default()
940 });
941
942 assert!(form.contains(&(
943 "grant_type".to_string(),
944 GRANT_TYPE_TOKEN_EXCHANGE.to_string()
945 )));
946 assert!(form.contains(&(
947 "subject_token_type".to_string(),
948 TOKEN_TYPE_ACCESS_TOKEN.to_string()
949 )));
950 assert!(form.contains(&(
951 "resource".to_string(),
952 "https://api-one.example.com".to_string()
953 )));
954 assert!(form.contains(&(
955 "resource".to_string(),
956 "https://api-two.example.com".to_string()
957 )));
958 assert!(form.contains(&("audience".to_string(), "api://inventory".to_string())));
959 }
960
961 #[test]
962 fn parse_token_response_requires_issued_token_type_for_exchange() {
963 let payload = json!({
964 "access_token": "new-token",
965 "token_type": "Bearer"
966 });
967
968 let error = parse_token_response(&payload, true).expect_err("missing issued token type");
969 let AuthplaneError::Auth(auth_error) = error else {
970 panic!("expected auth error");
971 };
972 assert_eq!(auth_error.code, "protocol_error");
973 }
974
975 #[test]
976 fn parse_token_response_preserves_issued_token_type() {
977 let payload = json!({
978 "access_token": "new-token",
979 "token_type": "Bearer",
980 "issued_token_type": TOKEN_TYPE_ACCESS_TOKEN
981 });
982
983 let response = parse_token_response(&payload, true).expect("valid token response");
984 assert_eq!(response.issued_token_type, TOKEN_TYPE_ACCESS_TOKEN);
985 }
986
987 #[test]
988 fn parse_token_response_rejects_unsupported_token_type() {
989 let payload = json!({
990 "access_token": "new-token",
991 "token_type": "mac"
992 });
993 let error = parse_token_response(&payload, false).expect_err("must reject token type");
994 let AuthplaneError::Auth(auth_error) = error else {
995 panic!("expected auth error");
996 };
997 assert_eq!(auth_error.code, "protocol_error");
998 assert!(auth_error.message.contains("unsupported token_type"));
999 }
1000
1001 #[test]
1002 fn parse_token_response_rejects_unsupported_issued_token_type() {
1003 let payload = json!({
1004 "access_token": "new-token",
1005 "token_type": "Bearer",
1006 "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token"
1007 });
1008 let error =
1009 parse_token_response(&payload, true).expect_err("must reject issued token type");
1010 let AuthplaneError::Auth(auth_error) = error else {
1011 panic!("expected auth error");
1012 };
1013 assert_eq!(auth_error.code, "protocol_error");
1014 assert!(auth_error.message.contains("unsupported issued_token_type"));
1015 }
1016
1017 #[test]
1018 fn parse_token_response_rejects_negative_expires_in() {
1019 let payload = json!({
1020 "access_token": "new-token",
1021 "token_type": "Bearer",
1022 "expires_in": -1
1023 });
1024 let error =
1025 parse_token_response(&payload, false).expect_err("must reject negative expires_in");
1026 let AuthplaneError::Auth(auth_error) = error else {
1027 panic!("expected auth error");
1028 };
1029 assert_eq!(auth_error.code, "protocol_error");
1030 assert!(auth_error.message.contains("must be non-negative"));
1031 }
1032
1033 #[test]
1034 fn parse_token_response_rejects_non_integer_expires_in() {
1035 let payload = json!({
1036 "access_token": "new-token",
1037 "token_type": "Bearer",
1038 "expires_in": "abc"
1039 });
1040 let error =
1041 parse_token_response(&payload, false).expect_err("must reject non-integer expires_in");
1042 let AuthplaneError::Auth(auth_error) = error else {
1043 panic!("expected auth error");
1044 };
1045 assert_eq!(auth_error.code, "protocol_error");
1046 assert!(auth_error.message.contains("must be an integer"));
1047 }
1048
1049 #[test]
1050 fn parse_token_response_missing_expires_in_becomes_none() {
1051 let payload = json!({
1057 "access_token": "new-token",
1058 "token_type": "Bearer"
1059 });
1060 let response = parse_token_response(&payload, false).expect("valid response");
1061 assert_eq!(response.expires_in, None);
1062 }
1063
1064 #[test]
1065 fn parse_token_response_explicit_zero_expires_in_becomes_some_zero() {
1066 let payload = json!({
1069 "access_token": "new-token",
1070 "token_type": "Bearer",
1071 "expires_in": 0
1072 });
1073 let response = parse_token_response(&payload, false).expect("valid response");
1074 assert_eq!(response.expires_in, Some(0));
1075 }
1076
1077 #[test]
1078 fn client_credentials_form_emits_one_resource_per_value() {
1079 let form = build_client_credentials_form(
1082 &["tools/read".to_string()],
1083 &[
1084 "https://api-one.example.com".to_string(),
1085 "https://api-two.example.com".to_string(),
1086 ],
1087 );
1088 let resource_entries: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
1089 assert_eq!(resource_entries.len(), 2);
1090 assert!(
1091 resource_entries
1092 .iter()
1093 .any(|(_, v)| v == "https://api-one.example.com")
1094 );
1095 assert!(
1096 resource_entries
1097 .iter()
1098 .any(|(_, v)| v == "https://api-two.example.com")
1099 );
1100 }
1101
1102 #[test]
1103 fn client_credentials_form_space_joins_multiple_scopes() {
1104 let form = build_client_credentials_form(
1107 &["tools/read".to_string(), "tools/write".to_string()],
1108 &[],
1109 );
1110 let scope = form
1111 .iter()
1112 .find(|(k, _)| k == "scope")
1113 .map(|(_, v)| v.clone())
1114 .expect("scope must be present");
1115 assert_eq!(scope, "tools/read tools/write");
1116 }
1117
1118 #[test]
1119 fn client_credentials_form_omits_empty_scope() {
1120 let form = build_client_credentials_form(&[], &[]);
1121 assert!(
1122 !form.iter().any(|(k, _)| k == "scope"),
1123 "scope must be omitted when empty"
1124 );
1125 }
1126
1127 #[test]
1128 fn token_exchange_form_uses_token_exchange_grant_type() {
1129 let options = TokenExchangeOptions {
1130 subject_token: "subject-1".to_string(),
1131 ..TokenExchangeOptions::default()
1132 };
1133 let form = build_token_exchange_form(&options);
1134 assert!(form.contains(&(
1135 "grant_type".to_string(),
1136 GRANT_TYPE_TOKEN_EXCHANGE.to_string()
1137 )));
1138 }
1139
1140 #[test]
1141 fn token_exchange_form_defaults_subject_token_type_when_missing() {
1142 let options = TokenExchangeOptions {
1145 subject_token: "subject-1".to_string(),
1146 ..TokenExchangeOptions::default()
1147 };
1148 let form = build_token_exchange_form(&options);
1149 let subject_type = form
1150 .iter()
1151 .find(|(k, _)| k == "subject_token_type")
1152 .map(|(_, v)| v.clone())
1153 .expect("subject_token_type must be present");
1154 assert_eq!(subject_type, TOKEN_TYPE_ACCESS_TOKEN);
1155 }
1156
1157 #[test]
1158 fn token_exchange_form_defaults_actor_token_type_only_when_actor_token_present() {
1159 let with_actor = TokenExchangeOptions {
1162 subject_token: "subject-1".to_string(),
1163 actor_token: "actor-1".to_string(),
1164 ..TokenExchangeOptions::default()
1165 };
1166 let form = build_token_exchange_form(&with_actor);
1167 assert!(
1168 form.iter()
1169 .any(|(k, v)| k == "actor_token_type" && v == TOKEN_TYPE_ACCESS_TOKEN)
1170 );
1171
1172 let without_actor = TokenExchangeOptions {
1173 subject_token: "subject-1".to_string(),
1174 ..TokenExchangeOptions::default()
1175 };
1176 let form = build_token_exchange_form(&without_actor);
1177 assert!(
1178 !form.iter().any(|(k, _)| k == "actor_token_type"),
1179 "actor_token_type must be omitted when actor_token is missing"
1180 );
1181 }
1182
1183 #[test]
1184 fn token_exchange_form_normalizes_empty_resource_and_audience() {
1185 let options = TokenExchangeOptions {
1187 subject_token: "subject-1".to_string(),
1188 resources: vec!["".to_string(), "https://api.example.com".to_string()],
1189 audiences: vec!["api://billing".to_string(), "".to_string()],
1190 ..TokenExchangeOptions::default()
1191 }
1192 .normalized();
1193 let form = build_token_exchange_form(&options);
1194 let resources: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
1195 let audiences: Vec<_> = form.iter().filter(|(k, _)| k == "audience").collect();
1196 assert_eq!(resources.len(), 1);
1197 assert_eq!(audiences.len(), 1);
1198 }
1199
1200 #[test]
1201 fn parse_token_exchange_error_without_status_code_still_maps_to_auth_error() {
1202 let error = parse_token_exchange_error(None, "not json at all");
1203 let AuthplaneError::Auth(auth_error) = error else {
1204 panic!("expected auth error");
1205 };
1206 assert_eq!(auth_error.code, "invalid_response");
1207 assert_eq!(auth_error.status_code, None);
1208 }
1209
1210 #[test]
1211 fn parse_token_exchange_error_401_maps_to_authentication_failure() {
1212 let body = r#"{"error":"invalid_client","error_description":"bad creds"}"#;
1213 let error = parse_token_exchange_error(Some(401), body);
1214 let AuthplaneError::Auth(auth_error) = error else {
1215 panic!("expected auth error");
1216 };
1217 assert_eq!(auth_error.code, "invalid_client");
1218 assert_eq!(auth_error.status_code, Some(401));
1219 }
1220
1221 #[test]
1222 fn introspection_response_deserialize_derives_cnf_jkt_from_cnf_object() {
1223 let payload = json!({
1230 "active": true,
1231 "token_type": "DPoP",
1232 "cnf": {"jkt": "abc"},
1233 });
1234 let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1235 assert!(response.active);
1236 assert_eq!(response.cnf_jkt, "abc");
1237 assert_eq!(
1238 response.cnf.and_then(|v| v.get("jkt").cloned()),
1239 Some(json!("abc"))
1240 );
1241 }
1242
1243 #[test]
1244 fn introspection_response_deserializes_with_absent_cnf_defaults_to_empty() {
1245 let payload = json!({"active": true, "token_type": "Bearer"});
1246 let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1247 assert!(response.active);
1248 assert_eq!(response.cnf, None);
1249 assert_eq!(response.cnf_jkt, "");
1250 }
1251
1252 #[test]
1253 fn introspection_response_round_trips_cnf_binding_through_serde() {
1254 let payload = json!({
1262 "active": true,
1263 "token_type": "DPoP",
1264 "cnf": {"jkt": "thumbprint-abc"},
1265 });
1266 let first: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1267 let serialized = serde_json::to_value(&first).unwrap();
1268 let second: IntrospectionResponse = serde_json::from_value(serialized).unwrap();
1269 assert_eq!(first, second);
1270 assert_eq!(second.cnf_jkt, "thumbprint-abc");
1271 }
1272
1273 #[test]
1274 fn introspection_response_deserialize_drops_non_object_cnf() {
1275 let payload = json!({"active": true, "cnf": "not-an-object"});
1279 let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1280 assert_eq!(response.cnf, None);
1281 assert_eq!(response.cnf_jkt, "");
1282 }
1283
1284 #[test]
1285 fn token_response_deserialize_derives_cnf_jkt_from_cnf_object() {
1286 let payload = json!({
1289 "access_token": "at",
1290 "token_type": "DPoP",
1291 "expires_in": 3600,
1292 "scope": "tools/echo",
1293 "cnf": {"jkt": "thumbprint-token"},
1294 });
1295 let response: TokenResponse = serde_json::from_value(payload).unwrap();
1296 assert_eq!(response.cnf_jkt, "thumbprint-token");
1297 assert_eq!(
1298 response.cnf.and_then(|v| v.get("jkt").cloned()),
1299 Some(json!("thumbprint-token"))
1300 );
1301 }
1302
1303 #[test]
1304 fn token_response_round_trips_cnf_binding_through_serde() {
1305 let payload = json!({
1306 "access_token": "at",
1307 "token_type": "DPoP",
1308 "expires_in": 3600,
1309 "scope": "tools/echo",
1310 "cnf": {"jkt": "thumbprint-token"},
1311 });
1312 let first: TokenResponse = serde_json::from_value(payload).unwrap();
1313 let serialized = serde_json::to_value(&first).unwrap();
1314 let second: TokenResponse = serde_json::from_value(serialized).unwrap();
1315 assert_eq!(first, second);
1316 assert_eq!(second.cnf_jkt, "thumbprint-token");
1317 }
1318
1319 #[test]
1320 fn token_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
1321 let payload = json!({
1327 "access_token": "at",
1328 "token_type": "DPoP",
1329 "expires_in": 3600,
1330 "scope": "tools/echo",
1331 "cnf": {"jkt": "from-cnf"},
1332 "cnf_jkt": "poisoned-top-level",
1333 });
1334 let response: TokenResponse = serde_json::from_value(payload).unwrap();
1335 assert_eq!(response.cnf_jkt, "from-cnf");
1336 }
1337
1338 #[test]
1339 fn introspection_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
1340 let payload = json!({
1341 "active": true,
1342 "token_type": "DPoP",
1343 "cnf": {"jkt": "from-cnf"},
1344 "cnf_jkt": "poisoned-top-level",
1345 });
1346 let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
1347 assert_eq!(response.cnf_jkt, "from-cnf");
1348 }
1349}