use std::collections::BTreeMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuth2SecurityScheme {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<Arc<str>>,
pub flows: OAuthFlows,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth2_metadata_url: Option<Url>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OAuthFlows {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_code: Option<AuthorizationCodeOAuthFlow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_credentials: Option<ClientCredentialsOAuthFlow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_code: Option<DeviceCodeOAuthFlow>,
}
impl OAuthFlows {
#[must_use]
pub const fn has_any(&self) -> bool {
self.authorization_code.is_some()
|| self.client_credentials.is_some()
|| self.device_code.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthorizationCodeOAuthFlow {
pub authorization_url: Url,
pub token_url: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_url: Option<Url>,
#[serde(default)]
pub scopes: BTreeMap<Arc<str>, Arc<str>>,
#[serde(default)]
pub pkce_required: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClientCredentialsOAuthFlow {
pub token_url: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_url: Option<Url>,
#[serde(default)]
pub scopes: BTreeMap<Arc<str>, Arc<str>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCodeOAuthFlow {
pub device_authorization_url: Url,
pub token_url: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_url: Option<Url>,
#[serde(default)]
pub scopes: BTreeMap<Arc<str>, Arc<str>>,
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use super::{
AuthorizationCodeOAuthFlow, ClientCredentialsOAuthFlow, DeviceCodeOAuthFlow,
OAuth2SecurityScheme, OAuthFlows,
};
use crate::domain::a2a::agent_card::security_scheme::SecurityScheme;
type TestError = Box<dyn std::error::Error>;
#[test]
fn oauth_flows_has_any_is_false_for_default() {
let flows = OAuthFlows::default();
assert!(!flows.has_any());
}
#[test]
fn oauth_flows_has_any_is_true_when_authorization_code_set() -> Result<(), TestError> {
let mut scopes = BTreeMap::new();
scopes.insert(Arc::from("read"), Arc::from("Read access"));
let flows = OAuthFlows {
authorization_code: Some(AuthorizationCodeOAuthFlow {
authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
token_url: url::Url::parse("https://auth.example.com/token")?,
refresh_url: None,
scopes: scopes.clone(),
pkce_required: true,
}),
client_credentials: None,
device_code: None,
};
assert!(flows.has_any());
Ok(())
}
#[test]
fn oauth_flows_serialises_with_camel_case_keys() -> Result<(), TestError> {
let mut scopes = BTreeMap::new();
scopes.insert(Arc::from("read"), Arc::from("Read access"));
let flows = OAuthFlows {
authorization_code: Some(AuthorizationCodeOAuthFlow {
authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
token_url: url::Url::parse("https://auth.example.com/token")?,
refresh_url: None,
scopes: scopes.clone(),
pkce_required: true,
}),
client_credentials: None,
device_code: Some(DeviceCodeOAuthFlow {
device_authorization_url: url::Url::parse("https://auth.example.com/device")?,
token_url: url::Url::parse("https://auth.example.com/token")?,
refresh_url: None,
scopes,
}),
};
let value = serde_json::to_value(&flows)?;
let Some(obj) = value.as_object() else {
return Err("OAuthFlows must serialise to a JSON object"
.to_owned()
.into());
};
assert!(obj.contains_key("authorizationCode"));
assert!(!obj.contains_key("clientCredentials"));
assert!(obj.contains_key("deviceCode"));
Ok(())
}
#[test]
fn oauth2_security_scheme_serialises_with_openapi_wire_form() -> Result<(), TestError> {
let mut scopes = BTreeMap::new();
scopes.insert(Arc::from("read"), Arc::from("Read access"));
let scheme = SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
description: Some(Arc::from("OAuth 2.0 with authorization code.")),
flows: OAuthFlows {
authorization_code: Some(AuthorizationCodeOAuthFlow {
authorization_url: url::Url::parse("https://auth.example.com/authorize")?,
token_url: url::Url::parse("https://auth.example.com/token")?,
refresh_url: None,
scopes: scopes.clone(),
pkce_required: true,
}),
client_credentials: None,
device_code: None,
},
oauth2_metadata_url: Some(url::Url::parse(
"https://auth.example.com/.well-known/oauth-authorization-server",
)?),
}));
let value = serde_json::to_value(&scheme)?;
let Some(obj) = value.as_object() else {
return Err("a SecurityScheme must serialise to a JSON object"
.to_owned()
.into());
};
assert_eq!(
obj.get("type").and_then(serde_json::Value::as_str),
Some("oauth2")
);
let Some(flows_obj) = obj.get("flows").and_then(serde_json::Value::as_object) else {
return Err("flows must be a JSON object".to_owned().into());
};
let Some(ac) = flows_obj
.get("authorizationCode")
.and_then(serde_json::Value::as_object)
else {
return Err("authorizationCode must be a JSON object".to_owned().into());
};
assert_eq!(
ac.get("authorizationUrl")
.and_then(serde_json::Value::as_str),
Some("https://auth.example.com/authorize")
);
assert_eq!(
ac.get("tokenUrl").and_then(serde_json::Value::as_str),
Some("https://auth.example.com/token")
);
assert_eq!(
ac.get("pkceRequired").and_then(serde_json::Value::as_bool),
Some(true)
);
let Some(scopes_obj) = ac.get("scopes").and_then(serde_json::Value::as_object) else {
return Err("scopes must be a JSON object".to_owned().into());
};
assert_eq!(
scopes_obj.get("read").and_then(serde_json::Value::as_str),
Some("Read access")
);
assert_eq!(
obj.get("oauth2MetadataUrl")
.and_then(serde_json::Value::as_str),
Some("https://auth.example.com/.well-known/oauth-authorization-server")
);
Ok(())
}
#[test]
fn oauth2_security_scheme_round_trips_through_serde() -> Result<(), TestError> {
let mut scopes = BTreeMap::new();
scopes.insert(Arc::from("read"), Arc::from("Read access"));
scopes.insert(Arc::from("write"), Arc::from("Write access"));
let scheme = SecurityScheme::OAuth2(Box::new(OAuth2SecurityScheme {
description: None,
flows: OAuthFlows {
authorization_code: None,
client_credentials: Some(ClientCredentialsOAuthFlow {
token_url: url::Url::parse("https://auth.example.com/token")?,
refresh_url: Some(url::Url::parse("https://auth.example.com/refresh")?),
scopes,
}),
device_code: None,
},
oauth2_metadata_url: None,
}));
let json = serde_json::to_string(&scheme)?;
let parsed: SecurityScheme = serde_json::from_str(&json)?;
assert_eq!(parsed, scheme);
Ok(())
}
}