use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::constants::{
auth_schemes, http_headers, http_methods, introspection_fields, jwt_claims, media_types,
};
use crate::errors::{
AuthError, AuthplaneError, map_oauth_error, protocol_error, transport_error, validation_error,
};
use crate::fetch_settings::FetchSettings;
use crate::transport::validate_fetch_url;
pub const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
pub const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct TokenExchangeOptions {
pub subject_token: String,
pub subject_token_type: String,
pub actor_token: String,
pub actor_token_type: String,
pub scope: String,
pub resources: Vec<String>,
pub audiences: Vec<String>,
}
impl TokenExchangeOptions {
pub fn normalized(&self) -> Self {
let mut normalized = self.clone();
normalized.resources.retain(|value| !value.is_empty());
normalized.audiences.retain(|value| !value.is_empty());
normalized
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "TokenResponseWire")]
#[non_exhaustive]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_in: Option<i64>,
pub scope: String,
pub refresh_token: String,
pub issued_token_type: String,
pub cnf: Option<Value>,
pub cnf_jkt: String,
}
#[derive(Deserialize)]
struct TokenResponseWire {
access_token: String,
token_type: String,
#[serde(default, deserialize_with = "deserialize_optional_expires_in")]
expires_in: Option<i64>,
scope: String,
#[serde(default)]
refresh_token: String,
#[serde(default)]
issued_token_type: String,
#[serde(default)]
cnf: Option<Value>,
}
impl From<TokenResponseWire> for TokenResponse {
fn from(wire: TokenResponseWire) -> Self {
let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
Self {
access_token: wire.access_token,
token_type: wire.token_type,
expires_in: wire.expires_in,
scope: wire.scope,
refresh_token: wire.refresh_token,
issued_token_type: wire.issued_token_type,
cnf,
cnf_jkt,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "IntrospectionResponseWire")]
#[non_exhaustive]
pub struct IntrospectionResponse {
pub active: bool,
pub scope: String,
pub client_id: String,
pub sub: String,
pub token_type: String,
pub iss: String,
pub aud: Option<Value>,
pub exp: Option<i64>,
pub iat: Option<i64>,
pub jti: String,
pub agent_id: String,
pub agent_chain: Vec<String>,
pub cnf: Option<Value>,
pub cnf_jkt: String,
}
#[derive(Deserialize)]
struct IntrospectionResponseWire {
active: bool,
#[serde(default)]
scope: String,
#[serde(default)]
client_id: String,
#[serde(default)]
sub: String,
#[serde(default)]
token_type: String,
#[serde(default)]
iss: String,
#[serde(default)]
aud: Option<Value>,
#[serde(default)]
exp: Option<i64>,
#[serde(default)]
iat: Option<i64>,
#[serde(default)]
jti: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
agent_chain: Vec<String>,
#[serde(default)]
cnf: Option<Value>,
}
impl From<IntrospectionResponseWire> for IntrospectionResponse {
fn from(wire: IntrospectionResponseWire) -> Self {
let (cnf, cnf_jkt) = normalize_cnf(wire.cnf);
Self {
active: wire.active,
scope: wire.scope,
client_id: wire.client_id,
sub: wire.sub,
token_type: wire.token_type,
iss: wire.iss,
aud: wire.aud,
exp: wire.exp,
iat: wire.iat,
jti: wire.jti,
agent_id: wire.agent_id,
agent_chain: wire.agent_chain,
cnf,
cnf_jkt,
}
}
}
pub fn parse_token_exchange_error(status_code: Option<u16>, body: &str) -> AuthplaneError {
let parsed: Result<Value, _> = serde_json::from_str(body);
match parsed {
Ok(payload) => map_oauth_error(status_code, &payload),
Err(_) => AuthplaneError::Auth(AuthError {
message: "OAuth request failed".to_string(),
code: "invalid_response".to_string(),
status_code,
}),
}
}
pub fn parse_token_response(
data: &Value,
allow_issued_token_type: bool,
) -> Result<TokenResponse, AuthplaneError> {
parse_token_response_inner(data, allow_issued_token_type, false)
}
pub fn parse_token_response_dpop(
data: &Value,
allow_issued_token_type: bool,
) -> Result<TokenResponse, AuthplaneError> {
parse_token_response_inner(data, allow_issued_token_type, true)
}
fn parse_token_response_inner(
data: &Value,
allow_issued_token_type: bool,
expect_dpop: bool,
) -> Result<TokenResponse, AuthplaneError> {
let access_token = required_string(data, "access_token")?;
let token_type = required_string(data, "token_type")?;
if !token_type.eq_ignore_ascii_case(auth_schemes::BEARER)
&& !token_type.eq_ignore_ascii_case(auth_schemes::DPOP)
{
return Err(protocol_error(&format!(
"unsupported token_type {token_type:?}; only {} and {} are supported",
auth_schemes::BEARER,
auth_schemes::DPOP
)));
}
if expect_dpop && !token_type.eq_ignore_ascii_case(auth_schemes::DPOP) {
return Err(protocol_error(&format!(
"DPoP proof was sent but token_type is {token_type:?}, not {:?}; \
the authorization server may have ignored the DPoP proof (RFC 9449 ยง5)",
auth_schemes::DPOP
)));
}
let issued_token_type = optional_string(data, "issued_token_type");
if allow_issued_token_type {
if issued_token_type.is_empty() {
return Err(protocol_error(
"token exchange response missing required 'issued_token_type' (RFC 8693 ยง2.2.1)",
));
}
if issued_token_type != TOKEN_TYPE_ACCESS_TOKEN {
return Err(protocol_error(&format!(
"unsupported issued_token_type {issued_token_type:?}; only access_token is supported"
)));
}
}
let (cnf, cnf_jkt) = extract_cnf_and_jkt(data);
Ok(TokenResponse {
access_token,
token_type,
expires_in: optional_non_negative_i64(data, "expires_in")?,
scope: optional_string(data, "scope"),
refresh_token: optional_string(data, "refresh_token"),
issued_token_type,
cnf,
cnf_jkt,
})
}
pub async fn client_credentials_grant(
http: &Client,
token_endpoint: &str,
auth_header: &str,
fetch_settings: &FetchSettings,
scopes: &[String],
resources: &[String],
dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
) -> Result<TokenResponse, AuthplaneError> {
let form = build_client_credentials_form(scopes, resources);
let expect_dpop = dpop_provider.is_some();
let (status, payload) = form_post_with_dpop(
http,
token_endpoint,
"token endpoint",
&form,
auth_header,
fetch_settings,
dpop_provider,
None,
)
.await?;
if crate::transport::is_http_success(status) {
return if expect_dpop {
parse_token_response_dpop(&payload, false)
} else {
parse_token_response(&payload, false)
};
}
Err(map_oauth_error(Some(status), &payload))
}
pub async fn exchange_token(
http: &Client,
token_endpoint: &str,
options: &TokenExchangeOptions,
auth_header: &str,
fetch_settings: &FetchSettings,
dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
) -> Result<TokenResponse, AuthplaneError> {
if options.subject_token.is_empty() {
return Err(validation_error("subject_token is required"));
}
let normalized = options.normalized();
let form = build_token_exchange_form(&normalized);
let expect_dpop = dpop_provider.is_some();
let (status, payload) = form_post_with_dpop(
http,
token_endpoint,
"token endpoint",
&form,
auth_header,
fetch_settings,
dpop_provider,
None,
)
.await?;
if crate::transport::is_http_success(status) {
return if expect_dpop {
parse_token_response_dpop(&payload, true)
} else {
parse_token_response(&payload, true)
};
}
Err(map_oauth_error(Some(status), &payload))
}
pub async fn introspect_token(
http: &Client,
introspection_endpoint: &str,
token: &str,
auth_header: &str,
fetch_settings: &FetchSettings,
dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
) -> Result<IntrospectionResponse, AuthplaneError> {
let form = [
(
crate::constants::oauth_params::TOKEN.to_string(),
token.to_string(),
),
(
crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
),
];
let (status, payload) = form_post_with_dpop(
http,
introspection_endpoint,
"introspection endpoint",
&form,
auth_header,
fetch_settings,
dpop_provider,
None,
)
.await?;
if crate::transport::is_http_success(status) {
let (cnf, cnf_jkt) = extract_cnf_and_jkt(&payload);
return Ok(IntrospectionResponse {
active: payload
.get(introspection_fields::ACTIVE)
.and_then(Value::as_bool)
.unwrap_or(false),
scope: optional_string(&payload, introspection_fields::SCOPE),
client_id: optional_string(&payload, jwt_claims::CLIENT_ID),
sub: optional_string(&payload, jwt_claims::SUB),
token_type: optional_string(&payload, introspection_fields::TOKEN_TYPE),
iss: optional_string(&payload, jwt_claims::ISS),
aud: payload.get(jwt_claims::AUD).cloned(),
exp: payload.get(jwt_claims::EXP).and_then(Value::as_i64),
iat: payload.get(jwt_claims::IAT).and_then(Value::as_i64),
jti: optional_string(&payload, jwt_claims::JTI),
agent_id: optional_string(&payload, jwt_claims::AGENT_ID),
agent_chain: crate::json_util::string_array(&payload, jwt_claims::AGENT_CHAIN),
cnf,
cnf_jkt,
});
}
Err(map_oauth_error(Some(status), &payload))
}
pub async fn revoke_token(
http: &Client,
revocation_endpoint: &str,
token: &str,
auth_header: &str,
fetch_settings: &FetchSettings,
dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
) -> Result<(), AuthplaneError> {
let form = [
(
crate::constants::oauth_params::TOKEN.to_string(),
token.to_string(),
),
(
crate::constants::oauth_params::TOKEN_TYPE_HINT.to_string(),
crate::constants::oauth_errors::TOKEN_TYPE_HINT_ACCESS_TOKEN.to_string(),
),
];
let (status, payload) = form_post_with_dpop(
http,
revocation_endpoint,
"revocation endpoint",
&form,
auth_header,
fetch_settings,
dpop_provider,
None,
)
.await?;
if crate::transport::is_http_success(status) {
return Ok(());
}
Err(map_oauth_error(Some(status), &payload))
}
fn required_string(data: &Value, key: &str) -> Result<String, AuthplaneError> {
let value = optional_string(data, key);
if value.is_empty() {
return Err(protocol_error(&format!(
"token response missing required field {key:?}"
)));
}
Ok(value)
}
fn optional_string(data: &Value, key: &str) -> String {
data.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_string()
}
fn extract_cnf_and_jkt(payload: &Value) -> (Option<Value>, String) {
normalize_cnf(payload.get("cnf").cloned())
}
fn normalize_cnf(cnf: Option<Value>) -> (Option<Value>, String) {
let cnf = cnf.filter(Value::is_object);
let cnf_jkt = cnf
.as_ref()
.and_then(Value::as_object)
.and_then(|map| map.get("jkt"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
(cnf, cnf_jkt)
}
enum ParseOptionalI64Error {
NotAnInteger { got: Option<String> },
Negative,
}
fn parse_optional_non_negative_i64(value: &Value) -> Result<Option<i64>, ParseOptionalI64Error> {
if value.is_null() {
return Ok(None);
}
let parsed = if let Some(number) = value.as_i64() {
number
} else if let Some(text) = value.as_str() {
text.parse::<i64>()
.map_err(|_| ParseOptionalI64Error::NotAnInteger {
got: Some(text.to_string()),
})?
} else {
return Err(ParseOptionalI64Error::NotAnInteger { got: None });
};
if parsed < 0 {
return Err(ParseOptionalI64Error::Negative);
}
Ok(Some(parsed))
}
fn optional_non_negative_i64(data: &Value, key: &str) -> Result<Option<i64>, AuthplaneError> {
let value = data.get(key).unwrap_or(&Value::Null);
parse_optional_non_negative_i64(value).map_err(|err| match err {
ParseOptionalI64Error::NotAnInteger { got: Some(text) } => protocol_error(&format!(
"token response field {key:?} must be an integer, got {text:?}"
)),
ParseOptionalI64Error::NotAnInteger { got: None } => {
protocol_error(&format!("token response field {key:?} must be an integer"))
}
ParseOptionalI64Error::Negative => protocol_error(&format!(
"token response field {key:?} must be non-negative"
)),
})
}
fn deserialize_optional_expires_in<'de, D>(deserializer: D) -> Result<Option<i64>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
let raw: Option<Value> = Option::deserialize(deserializer)?;
let value = raw.unwrap_or(Value::Null);
parse_optional_non_negative_i64(&value).map_err(|err| match err {
ParseOptionalI64Error::NotAnInteger { got: Some(text) } => {
D::Error::custom(format!("expires_in must be an integer, got {text:?}"))
}
ParseOptionalI64Error::NotAnInteger { got: None } => {
D::Error::custom("expires_in must be an integer")
}
ParseOptionalI64Error::Negative => D::Error::custom("expires_in must be non-negative"),
})
}
#[allow(clippy::too_many_arguments)] pub(crate) async fn form_post_with_dpop(
http: &Client,
url: &str,
url_label: &str,
form: &[(String, String)],
auth_header: &str,
fetch_settings: &FetchSettings,
dpop_provider: Option<&crate::dpop_provider::DpopProvider>,
dpop_access_token: Option<&str>,
) -> Result<(u16, Value), AuthplaneError> {
validate_fetch_url(url, fetch_settings, url_label)?;
let initial_dpop = match dpop_provider {
Some(provider) => Some(provider.build_proof(http_methods::POST, url, dpop_access_token)?),
None => None,
};
let (status, payload, nonce) =
do_form_post(http, url, form, auth_header, initial_dpop.as_deref()).await?;
if let Some(provider) = dpop_provider {
if !nonce.is_empty() {
let _ = provider.note_nonce(url, &nonce);
}
let error_code = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or_default();
if error_code == crate::constants::oauth_errors::USE_DPOP_NONCE && !nonce.is_empty() {
let retry_proof = provider.build_proof(http_methods::POST, url, dpop_access_token)?;
let (status2, payload2, nonce2) =
do_form_post(http, url, form, auth_header, Some(&retry_proof)).await?;
if !nonce2.is_empty() {
let _ = provider.note_nonce(url, &nonce2);
}
return Ok((status2, payload2));
}
}
Ok((status, payload))
}
async fn do_form_post(
http: &Client,
url: &str,
form: &[(String, String)],
auth_header: &str,
dpop_proof: Option<&str>,
) -> Result<(u16, Value, String), AuthplaneError> {
let mut request = http
.post(url)
.header(http_headers::AUTHORIZATION, auth_header)
.header(http_headers::ACCEPT, media_types::APPLICATION_JSON);
if let Some(proof) = dpop_proof {
request = request.header(http_headers::DPOP, proof);
}
let response = request
.form(form)
.send()
.await
.map_err(|error| transport_error(&error.to_string()))?;
let status = response.status().as_u16();
let nonce = response
.headers()
.get(http_headers::DPOP_NONCE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| transport_error(&error.to_string()))?;
let payload: Value = if bytes.is_empty() {
Value::Object(serde_json::Map::new())
} else {
serde_json::from_slice(&bytes).map_err(|error| transport_error(&error.to_string()))?
};
Ok((status, payload, nonce))
}
#[doc(hidden)]
pub fn build_client_credentials_form(
scopes: &[String],
resources: &[String],
) -> Vec<(String, String)> {
use crate::constants::oauth_params::*;
let mut form = vec![(
GRANT_TYPE.to_string(),
GRANT_TYPE_CLIENT_CREDENTIALS.to_string(),
)];
if !scopes.is_empty() {
form.push((SCOPE.to_string(), scopes.join(" ")));
}
for resource in resources.iter().filter(|value| !value.is_empty()) {
form.push((RESOURCE.to_string(), resource.clone()));
}
form
}
#[doc(hidden)]
pub fn build_token_exchange_form(options: &TokenExchangeOptions) -> Vec<(String, String)> {
use crate::constants::oauth_params::*;
let mut form = vec![
(
GRANT_TYPE.to_string(),
GRANT_TYPE_TOKEN_EXCHANGE.to_string(),
),
(SUBJECT_TOKEN.to_string(), options.subject_token.clone()),
(
SUBJECT_TOKEN_TYPE.to_string(),
if options.subject_token_type.is_empty() {
TOKEN_TYPE_ACCESS_TOKEN.to_string()
} else {
options.subject_token_type.clone()
},
),
];
if !options.actor_token.is_empty() {
form.push((ACTOR_TOKEN.to_string(), options.actor_token.clone()));
form.push((
ACTOR_TOKEN_TYPE.to_string(),
if options.actor_token_type.is_empty() {
TOKEN_TYPE_ACCESS_TOKEN.to_string()
} else {
options.actor_token_type.clone()
},
));
}
if !options.scope.is_empty() {
form.push((SCOPE.to_string(), options.scope.clone()));
}
for resource in &options.resources {
form.push((RESOURCE.to_string(), resource.clone()));
}
for audience in &options.audiences {
form.push((AUDIENCE.to_string(), audience.clone()));
}
form
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::{AuthplaneError, parse_token_exchange_error};
use super::{
GRANT_TYPE_TOKEN_EXCHANGE, IntrospectionResponse, TOKEN_TYPE_ACCESS_TOKEN,
TokenExchangeOptions, TokenResponse, build_client_credentials_form,
build_token_exchange_form, parse_token_response,
};
#[test]
fn maps_consent_required_to_typed_error() {
let body = r#"{
"error":"consent_required",
"error_description":"Consent needed",
"service_id":"drive",
"cause":"approval_needed",
"consent_url":"https://consent.example.com/start"
}"#;
let error = parse_token_exchange_error(Some(400), body);
let AuthplaneError::ConsentRequired(consent) = error else {
panic!("expected consent required");
};
assert_eq!(consent.code, "consent_required");
assert_eq!(consent.service_id, "drive");
assert_eq!(consent.cause_detail, "approval_needed");
assert_eq!(
consent.consent_url.as_deref(),
Some("https://consent.example.com/start")
);
}
#[test]
fn maps_interaction_required_to_typed_error() {
let body = r#"{
"error":"interaction_required",
"error_description":"User interaction required",
"service":"calendar"
}"#;
let error = parse_token_exchange_error(Some(400), body);
let AuthplaneError::ConsentRequired(consent) = error else {
panic!("expected consent required");
};
assert_eq!(consent.code, "interaction_required");
assert_eq!(consent.service_id, "calendar");
assert_eq!(consent.cause_detail, "User interaction required");
assert_eq!(consent.consent_url, None);
}
#[test]
fn maps_invalid_json_to_invalid_response_auth_error() {
let error = parse_token_exchange_error(Some(500), "{invalid-json");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "invalid_response");
assert_eq!(auth_error.message, "OAuth request failed");
assert_eq!(auth_error.status_code, Some(500));
}
#[test]
fn maps_non_consent_error_to_auth_error() {
let body = r#"{
"error":"invalid_target",
"error_description":"target missing"
}"#;
let error = parse_token_exchange_error(Some(400), body);
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "invalid_target");
assert_eq!(auth_error.message, "target missing");
assert!(auth_error.is_invalid_target());
assert!(!auth_error.is_access_denied());
assert!(!crate::should_open_circuit_for_oauth_error(
&auth_error.code
));
}
#[test]
fn maps_access_denied_to_auth_error_outside_the_breaker() {
let body = r#"{
"error":"access_denied",
"error_description":"client is not allowed to exchange for this resource"
}"#;
let error = parse_token_exchange_error(Some(403), body);
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "access_denied");
assert_eq!(auth_error.status_code, Some(403));
assert!(auth_error.is_access_denied());
assert!(!auth_error.is_invalid_target());
assert!(!crate::should_open_circuit_for_oauth_error(
&auth_error.code
));
}
#[test]
fn token_exchange_options_filter_empty_resource_and_audience_values() {
let options = TokenExchangeOptions {
subject_token: "subject".to_string(),
resources: vec!["".to_string(), "https://api.example.com".to_string()],
audiences: vec!["".to_string(), "api://billing".to_string()],
..TokenExchangeOptions::default()
}
.normalized();
assert_eq!(
options.resources,
vec!["https://api.example.com".to_string()]
);
assert_eq!(options.audiences, vec!["api://billing".to_string()]);
}
#[test]
fn client_credentials_form_includes_scope_and_resource() {
let form = build_client_credentials_form(
&["tools/read".to_string()],
&["https://api.example.com".to_string()],
);
assert!(form.contains(&("grant_type".to_string(), "client_credentials".to_string())));
assert!(form.contains(&("scope".to_string(), "tools/read".to_string())));
assert!(form.contains(&(
"resource".to_string(),
"https://api.example.com".to_string()
)));
}
#[test]
fn token_exchange_form_applies_defaults_and_repeated_values() {
let form = build_token_exchange_form(&TokenExchangeOptions {
subject_token: "subject-token".to_string(),
resources: vec![
"https://api-one.example.com".to_string(),
"https://api-two.example.com".to_string(),
],
audiences: vec!["api://inventory".to_string()],
..TokenExchangeOptions::default()
});
assert!(form.contains(&(
"grant_type".to_string(),
GRANT_TYPE_TOKEN_EXCHANGE.to_string()
)));
assert!(form.contains(&(
"subject_token_type".to_string(),
TOKEN_TYPE_ACCESS_TOKEN.to_string()
)));
assert!(form.contains(&(
"resource".to_string(),
"https://api-one.example.com".to_string()
)));
assert!(form.contains(&(
"resource".to_string(),
"https://api-two.example.com".to_string()
)));
assert!(form.contains(&("audience".to_string(), "api://inventory".to_string())));
}
#[test]
fn parse_token_response_requires_issued_token_type_for_exchange() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer"
});
let error = parse_token_response(&payload, true).expect_err("missing issued token type");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "protocol_error");
}
#[test]
fn parse_token_response_preserves_issued_token_type() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer",
"issued_token_type": TOKEN_TYPE_ACCESS_TOKEN
});
let response = parse_token_response(&payload, true).expect("valid token response");
assert_eq!(response.issued_token_type, TOKEN_TYPE_ACCESS_TOKEN);
}
#[test]
fn parse_token_response_rejects_unsupported_token_type() {
let payload = json!({
"access_token": "new-token",
"token_type": "mac"
});
let error = parse_token_response(&payload, false).expect_err("must reject token type");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "protocol_error");
assert!(auth_error.message.contains("unsupported token_type"));
}
#[test]
fn parse_token_response_rejects_unsupported_issued_token_type() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer",
"issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token"
});
let error =
parse_token_response(&payload, true).expect_err("must reject issued token type");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "protocol_error");
assert!(auth_error.message.contains("unsupported issued_token_type"));
}
#[test]
fn parse_token_response_rejects_negative_expires_in() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer",
"expires_in": -1
});
let error =
parse_token_response(&payload, false).expect_err("must reject negative expires_in");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "protocol_error");
assert!(auth_error.message.contains("must be non-negative"));
}
#[test]
fn parse_token_response_rejects_non_integer_expires_in() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer",
"expires_in": "abc"
});
let error =
parse_token_response(&payload, false).expect_err("must reject non-integer expires_in");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "protocol_error");
assert!(auth_error.message.contains("must be an integer"));
}
#[test]
fn parse_token_response_missing_expires_in_becomes_none() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer"
});
let response = parse_token_response(&payload, false).expect("valid response");
assert_eq!(response.expires_in, None);
}
#[test]
fn parse_token_response_explicit_zero_expires_in_becomes_some_zero() {
let payload = json!({
"access_token": "new-token",
"token_type": "Bearer",
"expires_in": 0
});
let response = parse_token_response(&payload, false).expect("valid response");
assert_eq!(response.expires_in, Some(0));
}
#[test]
fn client_credentials_form_emits_one_resource_per_value() {
let form = build_client_credentials_form(
&["tools/read".to_string()],
&[
"https://api-one.example.com".to_string(),
"https://api-two.example.com".to_string(),
],
);
let resource_entries: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
assert_eq!(resource_entries.len(), 2);
assert!(
resource_entries
.iter()
.any(|(_, v)| v == "https://api-one.example.com")
);
assert!(
resource_entries
.iter()
.any(|(_, v)| v == "https://api-two.example.com")
);
}
#[test]
fn client_credentials_form_space_joins_multiple_scopes() {
let form = build_client_credentials_form(
&["tools/read".to_string(), "tools/write".to_string()],
&[],
);
let scope = form
.iter()
.find(|(k, _)| k == "scope")
.map(|(_, v)| v.clone())
.expect("scope must be present");
assert_eq!(scope, "tools/read tools/write");
}
#[test]
fn client_credentials_form_omits_empty_scope() {
let form = build_client_credentials_form(&[], &[]);
assert!(
!form.iter().any(|(k, _)| k == "scope"),
"scope must be omitted when empty"
);
}
#[test]
fn token_exchange_form_uses_token_exchange_grant_type() {
let options = TokenExchangeOptions {
subject_token: "subject-1".to_string(),
..TokenExchangeOptions::default()
};
let form = build_token_exchange_form(&options);
assert!(form.contains(&(
"grant_type".to_string(),
GRANT_TYPE_TOKEN_EXCHANGE.to_string()
)));
}
#[test]
fn token_exchange_form_defaults_subject_token_type_when_missing() {
let options = TokenExchangeOptions {
subject_token: "subject-1".to_string(),
..TokenExchangeOptions::default()
};
let form = build_token_exchange_form(&options);
let subject_type = form
.iter()
.find(|(k, _)| k == "subject_token_type")
.map(|(_, v)| v.clone())
.expect("subject_token_type must be present");
assert_eq!(subject_type, TOKEN_TYPE_ACCESS_TOKEN);
}
#[test]
fn token_exchange_form_defaults_actor_token_type_only_when_actor_token_present() {
let with_actor = TokenExchangeOptions {
subject_token: "subject-1".to_string(),
actor_token: "actor-1".to_string(),
..TokenExchangeOptions::default()
};
let form = build_token_exchange_form(&with_actor);
assert!(
form.iter()
.any(|(k, v)| k == "actor_token_type" && v == TOKEN_TYPE_ACCESS_TOKEN)
);
let without_actor = TokenExchangeOptions {
subject_token: "subject-1".to_string(),
..TokenExchangeOptions::default()
};
let form = build_token_exchange_form(&without_actor);
assert!(
!form.iter().any(|(k, _)| k == "actor_token_type"),
"actor_token_type must be omitted when actor_token is missing"
);
}
#[test]
fn token_exchange_form_normalizes_empty_resource_and_audience() {
let options = TokenExchangeOptions {
subject_token: "subject-1".to_string(),
resources: vec!["".to_string(), "https://api.example.com".to_string()],
audiences: vec!["api://billing".to_string(), "".to_string()],
..TokenExchangeOptions::default()
}
.normalized();
let form = build_token_exchange_form(&options);
let resources: Vec<_> = form.iter().filter(|(k, _)| k == "resource").collect();
let audiences: Vec<_> = form.iter().filter(|(k, _)| k == "audience").collect();
assert_eq!(resources.len(), 1);
assert_eq!(audiences.len(), 1);
}
#[test]
fn parse_token_exchange_error_without_status_code_still_maps_to_auth_error() {
let error = parse_token_exchange_error(None, "not json at all");
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "invalid_response");
assert_eq!(auth_error.status_code, None);
}
#[test]
fn parse_token_exchange_error_401_maps_to_authentication_failure() {
let body = r#"{"error":"invalid_client","error_description":"bad creds"}"#;
let error = parse_token_exchange_error(Some(401), body);
let AuthplaneError::Auth(auth_error) = error else {
panic!("expected auth error");
};
assert_eq!(auth_error.code, "invalid_client");
assert_eq!(auth_error.status_code, Some(401));
}
#[test]
fn introspection_response_deserialize_derives_cnf_jkt_from_cnf_object() {
let payload = json!({
"active": true,
"token_type": "DPoP",
"cnf": {"jkt": "abc"},
});
let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
assert!(response.active);
assert_eq!(response.cnf_jkt, "abc");
assert_eq!(
response.cnf.and_then(|v| v.get("jkt").cloned()),
Some(json!("abc"))
);
}
#[test]
fn introspection_response_deserializes_with_absent_cnf_defaults_to_empty() {
let payload = json!({"active": true, "token_type": "Bearer"});
let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
assert!(response.active);
assert_eq!(response.cnf, None);
assert_eq!(response.cnf_jkt, "");
}
#[test]
fn introspection_response_round_trips_cnf_binding_through_serde() {
let payload = json!({
"active": true,
"token_type": "DPoP",
"cnf": {"jkt": "thumbprint-abc"},
});
let first: IntrospectionResponse = serde_json::from_value(payload).unwrap();
let serialized = serde_json::to_value(&first).unwrap();
let second: IntrospectionResponse = serde_json::from_value(serialized).unwrap();
assert_eq!(first, second);
assert_eq!(second.cnf_jkt, "thumbprint-abc");
}
#[test]
fn introspection_response_deserialize_drops_non_object_cnf() {
let payload = json!({"active": true, "cnf": "not-an-object"});
let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
assert_eq!(response.cnf, None);
assert_eq!(response.cnf_jkt, "");
}
#[test]
fn token_response_deserialize_derives_cnf_jkt_from_cnf_object() {
let payload = json!({
"access_token": "at",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "tools/echo",
"cnf": {"jkt": "thumbprint-token"},
});
let response: TokenResponse = serde_json::from_value(payload).unwrap();
assert_eq!(response.cnf_jkt, "thumbprint-token");
assert_eq!(
response.cnf.and_then(|v| v.get("jkt").cloned()),
Some(json!("thumbprint-token"))
);
}
#[test]
fn token_response_round_trips_cnf_binding_through_serde() {
let payload = json!({
"access_token": "at",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "tools/echo",
"cnf": {"jkt": "thumbprint-token"},
});
let first: TokenResponse = serde_json::from_value(payload).unwrap();
let serialized = serde_json::to_value(&first).unwrap();
let second: TokenResponse = serde_json::from_value(serialized).unwrap();
assert_eq!(first, second);
assert_eq!(second.cnf_jkt, "thumbprint-token");
}
#[test]
fn token_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
let payload = json!({
"access_token": "at",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "tools/echo",
"cnf": {"jkt": "from-cnf"},
"cnf_jkt": "poisoned-top-level",
});
let response: TokenResponse = serde_json::from_value(payload).unwrap();
assert_eq!(response.cnf_jkt, "from-cnf");
}
#[test]
fn introspection_response_deserialize_ignores_wire_cnf_jkt_when_it_disagrees_with_cnf() {
let payload = json!({
"active": true,
"token_type": "DPoP",
"cnf": {"jkt": "from-cnf"},
"cnf_jkt": "poisoned-top-level",
});
let response: IntrospectionResponse = serde_json::from_value(payload).unwrap();
assert_eq!(response.cnf_jkt, "from-cnf");
}
}