#[cfg(feature = "auth")]
use oauth2::{
AuthUrl, ClientId, ClientSecret, CsrfToken, IntrospectionUrl, RedirectUrl, Scope, TokenUrl,
basic::BasicClient,
};
#[cfg(feature = "auth")]
use openidconnect::{
ClaimsVerificationError, IssuerUrl, JsonWebKeySetUrl, Nonce, SignatureVerificationError,
core::{
CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenClaims, CoreIdTokenVerifier,
CoreJsonWebKeySet, CoreProviderMetadata,
},
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "auth")]
use std::{
sync::{Arc, RwLock},
time::{Duration, Instant},
};
use crate::{
domain::{
A2AError,
core::agent::{
AuthorizationCodeOAuthFlow, ClientCredentialsOAuthFlow, OAuthFlows, SecurityScheme,
},
},
port::authenticator::{AuthContext, AuthContextExtractor, AuthPrincipal, Authenticator},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuth2Token {
pub access_token: String,
pub token_type: String,
pub expires_in: Option<i64>,
pub refresh_token: Option<String>,
pub scope: Option<String>,
}
#[cfg(feature = "auth")]
#[derive(Debug, Clone, Deserialize)]
struct IntrospectionResponse {
active: bool,
#[serde(default)]
sub: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
exp: Option<i64>,
}
#[cfg(feature = "auth")]
#[derive(Clone)]
struct Introspection {
url: IntrospectionUrl,
http: reqwest::Client,
}
#[cfg(feature = "auth")]
#[derive(Clone)]
pub struct OAuth2Authenticator {
client_id: ClientId,
client_secret: Option<ClientSecret>,
auth_url: AuthUrl,
#[allow(dead_code)]
token_url: Option<TokenUrl>,
redirect_url: Option<RedirectUrl>,
scheme: SecurityScheme,
introspection: Option<Introspection>,
valid_tokens: Vec<String>,
}
#[cfg(feature = "auth")]
impl OAuth2Authenticator {
pub fn new_authorization_code(
client_id: ClientId,
client_secret: Option<ClientSecret>,
auth_url: AuthUrl,
token_url: TokenUrl,
redirect_url: RedirectUrl,
scopes: HashMap<String, String>,
) -> Self {
let flow = AuthorizationCodeOAuthFlow {
authorization_url: auth_url.as_str().to_string(),
token_url: token_url.as_str().to_string(),
refresh_url: String::new(),
scopes,
..Default::default()
};
let scheme = SecurityScheme::oauth2(
OAuthFlows::authorization_code(flow),
Some("OAuth2 Authorization Code Flow".to_string()),
None,
);
Self {
client_id,
client_secret,
auth_url,
token_url: Some(token_url),
redirect_url: Some(redirect_url),
scheme,
introspection: None,
valid_tokens: Vec::new(),
}
}
pub fn new_client_credentials(
client_id: ClientId,
client_secret: ClientSecret,
token_url: TokenUrl,
scopes: HashMap<String, String>,
) -> Self {
let auth_url = AuthUrl::new("http://localhost".to_string())
.expect("localhost URL should always be valid");
let flow = ClientCredentialsOAuthFlow {
token_url: token_url.as_str().to_string(),
refresh_url: String::new(),
scopes,
..Default::default()
};
let scheme = SecurityScheme::oauth2(
OAuthFlows::client_credentials(flow),
Some("OAuth2 Client Credentials Flow".to_string()),
None,
);
Self {
client_id,
client_secret: Some(client_secret),
auth_url,
token_url: Some(token_url),
redirect_url: None,
scheme,
introspection: None,
valid_tokens: Vec::new(),
}
}
pub fn with_introspection(mut self, url: IntrospectionUrl) -> Result<Self, A2AError> {
let http = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| A2AError::Internal(format!("Failed to build HTTP client: {}", e)))?;
self.introspection = Some(Introspection { url, http });
Ok(self)
}
pub fn with_valid_tokens(mut self, tokens: Vec<String>) -> Self {
self.valid_tokens = tokens;
self
}
async fn introspect(
&self,
introspection: &Introspection,
token: &str,
) -> Result<AuthPrincipal, A2AError> {
let response = introspection
.http
.post(introspection.url.as_str())
.basic_auth(
self.client_id.as_str(),
self.client_secret.as_ref().map(|secret| secret.secret()),
)
.form(&[("token", token), ("token_type_hint", "access_token")])
.send()
.await
.map_err(|e| {
A2AError::Internal(format!(
"OAuth2 token introspection failed: {}",
crate::adapter::error::describe_transport_error(&e)
))
})?;
let status = response.status();
if !status.is_success() {
return Err(A2AError::Internal(format!(
"OAuth2 token introspection endpoint answered {}",
status
)));
}
let claims: IntrospectionResponse = response.json().await.map_err(|e| {
A2AError::Internal(format!(
"OAuth2 token introspection returned an unreadable body: {}",
e
))
})?;
if !claims.active {
return Err(A2AError::Internal(
"Invalid OAuth2 access token".to_string(),
));
}
let subject = claims
.sub
.clone()
.or_else(|| claims.username.clone())
.or_else(|| claims.client_id.clone())
.ok_or_else(|| {
A2AError::Internal(
"OAuth2 token introspection named no subject (`sub`, `username` or \
`client_id`) — there is nothing to attribute the request to"
.to_string(),
)
})?;
let mut principal = AuthPrincipal::new(subject, "oauth2".to_string());
if let Some(scope) = claims.scope {
principal = principal.with_attribute("scope".to_string(), scope);
}
if let Some(client_id) = claims.client_id {
principal = principal.with_attribute("client_id".to_string(), client_id);
}
if let Some(exp) = claims.exp {
principal = principal.with_attribute("exp".to_string(), exp.to_string());
}
Ok(principal)
}
pub fn authorize_url(&self) -> (String, CsrfToken) {
let mut client =
BasicClient::new(self.client_id.clone()).set_auth_uri(self.auth_url.clone());
if let Some(ref secret) = self.client_secret {
client = client.set_client_secret(secret.clone());
}
if let Some(ref redirect_url) = self.redirect_url {
client = client.set_redirect_uri(redirect_url.clone());
}
let (auth_url, csrf_token) = client
.authorize_url(CsrfToken::new_random)
.add_scope(Scope::new("read".to_string()))
.url();
(auth_url.to_string(), csrf_token)
}
}
#[cfg(feature = "auth")]
#[async_trait]
impl Authenticator for OAuth2Authenticator {
async fn authenticate(&self, context: &AuthContext) -> Result<AuthPrincipal, A2AError> {
self.validate_context(context)?;
let token = &context.credential;
let mut principal = match &self.introspection {
Some(introspection) => self.introspect(introspection, token).await?,
None if self.valid_tokens.contains(token) => {
AuthPrincipal::new(format!("oauth2:{}", token), "oauth2".to_string())
}
None => {
return Err(A2AError::Internal(
"Invalid OAuth2 access token".to_string(),
));
}
};
if let Some(scope) = context.get_metadata("scope")
&& !principal.attributes.contains_key("scope")
{
principal = principal.with_attribute("scope".to_string(), scope.clone());
}
Ok(principal)
}
fn security_scheme(&self) -> &SecurityScheme {
&self.scheme
}
fn validate_context(&self, context: &AuthContext) -> Result<(), A2AError> {
if context.scheme_type != "oauth2" {
return Err(A2AError::Internal(format!(
"Invalid authentication scheme: expected 'oauth2', got '{}'",
context.scheme_type
)));
}
Ok(())
}
}
#[cfg(feature = "auth")]
struct SigningKeys {
jwks_uri: JsonWebKeySetUrl,
http: reqwest::Client,
state: RwLock<KeyState>,
}
#[cfg(feature = "auth")]
struct KeyState {
set: CoreJsonWebKeySet,
fetched_at: Instant,
}
#[cfg(feature = "auth")]
impl SigningKeys {
fn current(&self) -> CoreJsonWebKeySet {
self.read().set.clone()
}
fn read(&self) -> std::sync::RwLockReadGuard<'_, KeyState> {
self.state.read().unwrap_or_else(|e| e.into_inner())
}
async fn refetch(&self, min_interval: Duration) -> Result<Option<CoreJsonWebKeySet>, A2AError> {
if self.read().fetched_at.elapsed() < min_interval {
return Ok(None);
}
let set = CoreJsonWebKeySet::fetch_async(&self.jwks_uri, &self.http)
.await
.map_err(|e| {
A2AError::Internal(format!("Failed to fetch the OIDC provider's keys: {}", e))
})?;
let mut state = self.state.write().unwrap_or_else(|e| e.into_inner());
state.set = set.clone();
state.fetched_at = Instant::now();
Ok(Some(set))
}
}
#[cfg(feature = "auth")]
fn is_missing_key(error: &ClaimsVerificationError) -> bool {
matches!(
error,
ClaimsVerificationError::SignatureVerification(
SignatureVerificationError::NoMatchingKey
| SignatureVerificationError::AmbiguousKeyId(_)
)
)
}
#[cfg(feature = "auth")]
fn any_nonce(_: Option<&Nonce>) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "auth")]
#[derive(Clone)]
pub struct OpenIdConnectAuthenticator {
client_id: ClientId,
client_secret: Option<ClientSecret>,
issuer: IssuerUrl,
provider_metadata: CoreProviderMetadata,
redirect_url: RedirectUrl,
scheme: SecurityScheme,
keys: Arc<SigningKeys>,
key_refetch_interval: Duration,
}
#[cfg(feature = "auth")]
impl OpenIdConnectAuthenticator {
pub const DEFAULT_KEY_REFETCH_INTERVAL: Duration = Duration::from_secs(60);
pub async fn new(
issuer_url: IssuerUrl,
client_id: ClientId,
client_secret: Option<ClientSecret>,
redirect_url: RedirectUrl,
) -> Result<Self, A2AError> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| A2AError::Internal(format!("Failed to build HTTP client: {}", e)))?;
let provider_metadata =
CoreProviderMetadata::discover_async(issuer_url.clone(), &http_client)
.await
.map_err(|e| {
A2AError::Internal(format!("Failed to discover OIDC provider: {}", e))
})?;
let scheme = SecurityScheme::open_id_connect(
issuer_url.as_str().to_string(),
Some("OpenID Connect authentication".to_string()),
);
let keys = SigningKeys {
jwks_uri: provider_metadata.jwks_uri().clone(),
http: http_client,
state: RwLock::new(KeyState {
set: provider_metadata.jwks().clone(),
fetched_at: Instant::now(),
}),
};
Ok(Self {
client_id,
client_secret,
issuer: issuer_url,
provider_metadata,
redirect_url,
scheme,
keys: Arc::new(keys),
key_refetch_interval: Self::DEFAULT_KEY_REFETCH_INTERVAL,
})
}
pub fn with_key_refetch_interval(mut self, interval: Duration) -> Self {
self.key_refetch_interval = interval;
self
}
fn verified<'t>(
&self,
id_token: &'t CoreIdToken,
keys: CoreJsonWebKeySet,
) -> Result<&'t CoreIdTokenClaims, ClaimsVerificationError> {
let verifier = match &self.client_secret {
Some(secret) => CoreIdTokenVerifier::new_confidential_client(
self.client_id.clone(),
secret.clone(),
self.issuer.clone(),
keys,
),
None => CoreIdTokenVerifier::new_public_client(
self.client_id.clone(),
self.issuer.clone(),
keys,
),
};
id_token.claims(&verifier, any_nonce)
}
pub fn authorize_url(&self) -> (String, CsrfToken, Nonce) {
let client = CoreClient::from_provider_metadata(
self.provider_metadata.clone(),
self.client_id.clone(),
self.client_secret.clone(),
)
.set_redirect_uri(self.redirect_url.clone());
let (auth_url, csrf_token, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.url();
(auth_url.to_string(), csrf_token, nonce)
}
}
#[cfg(feature = "auth")]
#[async_trait]
impl Authenticator for OpenIdConnectAuthenticator {
async fn authenticate(&self, context: &AuthContext) -> Result<AuthPrincipal, A2AError> {
self.validate_context(context)?;
let id_token: CoreIdToken = context.credential.parse().map_err(|e| {
A2AError::Internal(format!(
"Invalid OpenID Connect ID token: not a well-formed ID token ({})",
e
))
})?;
let mut failure = match self.verified(&id_token, self.keys.current()) {
Ok(claims) => return Ok(principal_from(claims)),
Err(e) => e,
};
if is_missing_key(&failure)
&& let Some(rotated) = self.keys.refetch(self.key_refetch_interval).await?
{
match self.verified(&id_token, rotated) {
Ok(claims) => return Ok(principal_from(claims)),
Err(e) => failure = e,
}
}
Err(A2AError::Internal(format!(
"Invalid OpenID Connect ID token: {}",
failure
)))
}
fn security_scheme(&self) -> &SecurityScheme {
&self.scheme
}
fn validate_context(&self, context: &AuthContext) -> Result<(), A2AError> {
if context.scheme_type != "openidconnect" && context.scheme_type != "oauth2" {
return Err(A2AError::Internal(format!(
"Invalid authentication scheme: expected 'openidconnect', got '{}'",
context.scheme_type
)));
}
Ok(())
}
}
#[cfg(feature = "auth")]
fn principal_from(claims: &CoreIdTokenClaims) -> AuthPrincipal {
let mut principal = AuthPrincipal::new(
claims.subject().as_str().to_string(),
"openidconnect".to_string(),
);
principal = principal.with_attribute("iss".to_string(), claims.issuer().as_str().to_string());
principal = principal.with_attribute(
"exp".to_string(),
claims.expiration().timestamp().to_string(),
);
if let Some(email) = claims.email() {
principal = principal.with_attribute("email".to_string(), email.as_str().to_string());
}
if let Some(username) = claims.preferred_username() {
principal =
principal.with_attribute("preferred_username".to_string(), username.to_string());
}
principal
}
#[derive(Clone)]
pub struct OAuth2Extractor;
#[async_trait]
impl AuthContextExtractor for OAuth2Extractor {
#[cfg(feature = "http-server")]
async fn extract_from_headers(&self, headers: &axum::http::HeaderMap) -> Option<AuthContext> {
headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|auth| {
let parts: Vec<&str> = auth.splitn(2, ' ').collect();
if parts.len() == 2 && parts[0].to_lowercase() == "bearer" {
Some(AuthContext::new("oauth2".to_string(), parts[1].to_string()))
} else {
None
}
})
}
#[cfg(not(feature = "http-server"))]
async fn extract_from_headers(&self, headers: &HashMap<String, String>) -> Option<AuthContext> {
headers
.get("authorization")
.or_else(|| headers.get("Authorization"))
.and_then(|auth| {
let parts: Vec<&str> = auth.splitn(2, ' ').collect();
if parts.len() == 2 && parts[0].to_lowercase() == "bearer" {
Some(AuthContext::new("oauth2".to_string(), parts[1].to_string()))
} else {
None
}
})
}
async fn extract_from_query(&self, params: &HashMap<String, String>) -> Option<AuthContext> {
params.get("access_token").map(|token| {
AuthContext::new("oauth2".to_string(), token.clone())
.with_metadata("location".to_string(), "query".to_string())
})
}
async fn extract_from_cookies(&self, _cookies: &str) -> Option<AuthContext> {
None
}
}
#[cfg(not(feature = "auth"))]
pub struct OAuth2Authenticator;
#[cfg(not(feature = "auth"))]
pub struct OpenIdConnectAuthenticator;
#[cfg(not(feature = "auth"))]
impl OAuth2Authenticator {
pub fn new_authorization_code(
_client_id: String,
_auth_url: String,
_token_url: String,
) -> Self {
compile_error!("OAuth2 authentication requires the 'auth' feature");
}
}