use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use rvoip_core_traits::identity::IdentityAssurance;
use rvoip_core_traits::ids::IdentityId;
use serde::Deserialize;
use url::Url;
use crate::bearer::{BearerAuthError, BearerValidator};
#[derive(Clone)]
pub struct OAuth2IntrospectionValidator {
inner: Arc<Inner>,
}
#[derive(Clone)]
struct Inner {
endpoint: Url,
client: reqwest::Client,
client_auth: Option<IntrospectionClientAuth>,
issuers: Option<HashSet<String>>,
audiences: Option<HashSet<String>>,
}
#[derive(Clone)]
enum IntrospectionClientAuth {
Basic {
client_id: String,
client_secret: String,
},
Bearer(String),
}
#[derive(Debug, 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)]
scopes: Option<Vec<String>>,
#[serde(default)]
iss: Option<String>,
#[serde(default)]
aud: Option<IntrospectionAudience>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum IntrospectionAudience {
One(String),
Many(Vec<String>),
}
impl OAuth2IntrospectionValidator {
pub fn new(endpoint: Url) -> Self {
let client = reqwest::Client::builder()
.user_agent("rvoip-auth-core/0.1 (oauth2-introspection)")
.timeout(Duration::from_secs(10))
.build()
.expect("reqwest::Client::builder default config never fails");
Self {
inner: Arc::new(Inner {
endpoint,
client,
client_auth: None,
issuers: None,
audiences: None,
}),
}
}
pub fn with_basic_client_auth(
self,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
let inner = &*self.inner;
Self {
inner: Arc::new(Inner {
endpoint: inner.endpoint.clone(),
client: inner.client.clone(),
client_auth: Some(IntrospectionClientAuth::Basic {
client_id: client_id.into(),
client_secret: client_secret.into(),
}),
issuers: inner.issuers.clone(),
audiences: inner.audiences.clone(),
}),
}
}
pub fn with_bearer_client_auth(self, token: impl Into<String>) -> Self {
let inner = &*self.inner;
Self {
inner: Arc::new(Inner {
endpoint: inner.endpoint.clone(),
client: inner.client.clone(),
client_auth: Some(IntrospectionClientAuth::Bearer(token.into())),
issuers: inner.issuers.clone(),
audiences: inner.audiences.clone(),
}),
}
}
pub fn with_issuer<I, S>(self, issuers: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let inner = &*self.inner;
Self {
inner: Arc::new(Inner {
endpoint: inner.endpoint.clone(),
client: inner.client.clone(),
client_auth: inner.client_auth.clone(),
issuers: Some(
issuers
.into_iter()
.map(|issuer| issuer.as_ref().to_string())
.collect(),
),
audiences: inner.audiences.clone(),
}),
}
}
pub fn with_audience<I, S>(self, audiences: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let inner = &*self.inner;
Self {
inner: Arc::new(Inner {
endpoint: inner.endpoint.clone(),
client: inner.client.clone(),
client_auth: inner.client_auth.clone(),
issuers: inner.issuers.clone(),
audiences: Some(
audiences
.into_iter()
.map(|audience| audience.as_ref().to_string())
.collect(),
),
}),
}
}
pub fn into_arc(self) -> Arc<dyn BearerValidator> {
Arc::new(self)
}
async fn introspect(&self, token: &str) -> Result<IntrospectionResponse, BearerAuthError> {
let mut request = self
.inner
.client
.post(self.inner.endpoint.clone())
.form(&[("token", token)]);
if let Some(client_auth) = &self.inner.client_auth {
request = match client_auth {
IntrospectionClientAuth::Basic {
client_id,
client_secret,
} => request.basic_auth(client_id, Some(client_secret)),
IntrospectionClientAuth::Bearer(token) => request.bearer_auth(token),
};
}
let response = request
.send()
.await
.map_err(|err| BearerAuthError::Unavailable(format!("introspection: {err}")))?;
if !response.status().is_success() {
return Err(BearerAuthError::Unavailable(format!(
"introspection endpoint returned {}",
response.status()
)));
}
response
.json::<IntrospectionResponse>()
.await
.map_err(|err| BearerAuthError::Unavailable(format!("introspection parse: {err}")))
}
}
#[async_trait]
impl BearerValidator for OAuth2IntrospectionValidator {
async fn validate(&self, token: &str) -> Result<IdentityAssurance, BearerAuthError> {
if token.is_empty() {
return Err(BearerAuthError::Empty);
}
let response = self.introspect(token).await?;
if !response.active {
return Err(BearerAuthError::Invalid("inactive token".into()));
}
validate_issuer(&response, self.inner.issuers.as_ref())?;
validate_audience(&response, self.inner.audiences.as_ref())?;
let subject = response
.sub
.or(response.username)
.or(response.client_id)
.ok_or_else(|| {
BearerAuthError::Invalid("active introspection response missing subject".into())
})?;
let identity = IdentityId::from_string(subject);
let scopes = scopes_from_response(response.scope, response.scopes);
Ok(IdentityAssurance::UserAuthorized {
identity: identity.clone(),
user_id: identity,
scopes,
})
}
}
fn validate_issuer(
response: &IntrospectionResponse,
expected: Option<&HashSet<String>>,
) -> Result<(), BearerAuthError> {
let Some(expected) = expected else {
return Ok(());
};
let Some(issuer) = response.iss.as_ref() else {
return Err(BearerAuthError::Invalid(
"introspection response missing issuer".into(),
));
};
if expected.contains(issuer) {
Ok(())
} else {
Err(BearerAuthError::Invalid(
"introspection issuer mismatch".into(),
))
}
}
fn validate_audience(
response: &IntrospectionResponse,
expected: Option<&HashSet<String>>,
) -> Result<(), BearerAuthError> {
let Some(expected) = expected else {
return Ok(());
};
let audiences: Vec<&str> = match response.aud.as_ref() {
Some(IntrospectionAudience::One(audience)) => vec![audience.as_str()],
Some(IntrospectionAudience::Many(audiences)) => {
audiences.iter().map(String::as_str).collect()
}
None => Vec::new(),
};
if audiences
.iter()
.any(|audience| expected.contains(*audience))
{
Ok(())
} else {
Err(BearerAuthError::Invalid(
"introspection audience mismatch".into(),
))
}
}
fn scopes_from_response(scope: Option<String>, scopes: Option<Vec<String>>) -> Vec<String> {
let mut values = Vec::new();
if let Some(scope) = scope {
values.extend(scope.split_whitespace().map(str::to_string));
}
if let Some(scopes) = scopes {
for scope in scopes {
if !values.contains(&scope) {
values.push(scope);
}
}
}
values
}