pub mod error;
use std::{marker::PhantomData, sync::Arc};
pub use error::IntrospectionValidateError;
use http::HeaderName;
use serde::Deserialize;
use snafu::ResultExt as _;
use crate::{
core::{
EndpointUrl, Error,
client_auth::ClientAuthentication,
crypto::verifier::{JwsVerifierFactory, JwsVerifierPlatform},
http::HttpClient,
jwk::JwksSource,
jwt::{JtiUniquenessChecker, validator::ClaimCheck},
platform::{Duration, MaybeSendSync},
server_metadata::AuthorizationServerMetadata,
},
introspection::TokenIntrospection,
validator::{
AccessTokenValidator, ValidatedRequest, ValidationResult,
binding::{DPoPBindingChecker, check_token_binding},
dpop_nonce::DPoPNonceChecker,
dpop_proof::DPoPProofValidator,
extract::extract_token,
introspection::{
error::{AudienceSnafu, BindingSnafu, CallSnafu, ExtractSnafu},
introspection_validator_builder::{
SetIntrospectionEndpoint, SetIssuer, SetJwksUri, SetTokenEndpoint, State,
},
},
metadata::{ProvideValidatorMetadata, ValidatorMetadata},
observe::{OnValidate, ValidationOutcome},
},
};
pub struct IntrospectionValidator<Claims = ()> {
token_introspection: TokenIntrospection,
http_client: Arc<dyn HttpClient>,
dpop_binding_checker: DPoPBindingChecker,
token_header: HeaderName,
on_validate: Option<Arc<dyn OnValidate>>,
issuer: Option<String>,
realm: Option<String>,
resource_metadata: Option<String>,
audience: ClaimCheck,
require_mtls: bool,
_phantom: PhantomData<Claims>,
}
#[bon::bon]
impl<Claims: for<'de> Deserialize<'de> + Clone + 'static> IntrospectionValidator<Claims> {
#[builder(
start_fn(vis = "", name = "builder_internal"),
generics(setters(vis = "", name = "with_{}_internal")),
on(String, into)
)]
pub async fn new(
client_id: String,
issuer: Option<String>,
introspection_endpoint: EndpointUrl,
token_endpoint: Option<EndpointUrl>,
#[builder(into)]
audience: ClaimCheck,
#[builder(with = |auth: impl ClientAuthentication + 'static| Arc::new(auth) as Arc<dyn ClientAuthentication>)]
client_auth: Arc<dyn ClientAuthentication>,
#[builder(default)]
request_jwt_response: bool,
#[builder(with = |client: impl HttpClient + 'static| Arc::new(client) as Arc<dyn HttpClient>)]
http_client: Arc<dyn HttpClient>,
#[builder(into)]
allowed_dpop_signing_algorithms: Option<Vec<String>>,
#[builder(default = Duration::from_mins(1))]
max_dpop_proof_age: Duration,
#[builder(default = super::DEFAULT_CLOCK_LEEWAY)]
clock_leeway: Duration,
#[builder(default)]
require_dpop: bool,
#[builder(default)]
require_mtls: bool,
jwks_uri: Option<EndpointUrl>,
#[cfg_attr(feature = "default-jws-verifier-platform", builder(default = crate::DefaultJwsVerifierPlatform::default().into()))]
jws_verifier_platform: Arc<dyn JwsVerifierPlatform>,
#[builder(with = |checker: impl DPoPNonceChecker + 'static| Arc::new(checker) as Arc<dyn DPoPNonceChecker>)]
dpop_nonce_checker: Option<Arc<dyn DPoPNonceChecker>>,
dpop_jti_checker: Option<Arc<dyn JtiUniquenessChecker>>,
#[builder(default = Arc::new(JwksSource::builder().http_client(http_client.clone()).build()))]
jws_verifier_factory: Arc<dyn JwsVerifierFactory>,
#[builder(default = http::header::AUTHORIZATION)]
token_header: HeaderName,
realm: Option<String>,
resource_metadata: Option<String>,
on_validate: Option<Arc<dyn OnValidate>>,
) -> Result<Self, Error> {
let token_introspection = TokenIntrospection::builder()
.client_id(client_id.clone())
.maybe_issuer(issuer.clone())
.introspection_endpoint(introspection_endpoint)
.maybe_token_endpoint(token_endpoint)
.client_auth(client_auth)
.request_jwt_response(request_jwt_response)
.maybe_jwks_uri(jwks_uri)
.jws_verifier_factory(jws_verifier_factory)
.jws_verifier_platform(jws_verifier_platform.clone())
.clock_leeway(clock_leeway)
.build()
.await?;
Ok(Self {
token_introspection,
http_client,
dpop_binding_checker: DPoPBindingChecker {
dpop_nonce_checker,
proof_validator: DPoPProofValidator::builder()
.jws_verifier_platform(jws_verifier_platform)
.max_proof_age(max_dpop_proof_age)
.clock_leeway(clock_leeway)
.maybe_allowed_signing_algorithms(allowed_dpop_signing_algorithms)
.maybe_jti_checker(dpop_jti_checker)
.build(),
required: require_dpop,
},
token_header,
on_validate,
issuer,
realm,
resource_metadata,
audience,
require_mtls,
_phantom: PhantomData,
})
}
}
impl IntrospectionValidator<()> {
pub fn builder() -> IntrospectionValidatorBuilder<()> {
IntrospectionValidator::builder_internal()
}
#[allow(clippy::type_complexity)]
pub fn builder_from_metadata(
metadata: &AuthorizationServerMetadata,
) -> Option<
IntrospectionValidatorBuilder<
(),
SetTokenEndpoint<SetJwksUri<SetIntrospectionEndpoint<SetIssuer>>>,
>,
> {
metadata
.introspection_endpoint
.as_ref()
.map(|introspection_endpoint| {
Self::builder()
.issuer(metadata.issuer.clone())
.introspection_endpoint(introspection_endpoint.clone())
.maybe_jwks_uri(metadata.jwks_uri.clone())
.token_endpoint(metadata.token_endpoint.clone())
})
}
}
impl<Claims: for<'de> Deserialize<'de> + Clone + 'static, S: State>
IntrospectionValidatorBuilder<Claims, S>
{
pub fn with_claims<Claims1: for<'de> Deserialize<'de> + Clone + 'static>(
self,
) -> IntrospectionValidatorBuilder<Claims1, S> {
self.with_claims_internal()
}
}
impl<Claims: for<'de> Deserialize<'de> + Clone + 'static> IntrospectionValidator<Claims> {
pub fn validator_metadata(&self, resource: Option<&str>) -> ValidatorMetadata {
ValidatorMetadata {
realm: self.realm.clone(),
authorization_servers: self.issuer.as_ref().map(|s| vec![s.clone()]),
dpop_supported: Some(true),
dpop_signing_alg_values_supported: self
.dpop_binding_checker
.proof_validator
.allowed_signing_algorithms()
.map(<[_]>::to_vec),
dpop_bound_access_tokens_required: Some(self.dpop_binding_checker.required),
tls_client_certificate_bound_access_tokens: self.require_mtls.then_some(true),
resource: resource.map(std::borrow::ToOwned::to_owned),
bearer_methods_supported: Some(vec!["header"]),
resource_metadata: self.resource_metadata.clone(),
}
}
pub async fn validate_request(
&self,
headers: &http::HeaderMap,
http_method: &http::Method,
http_uri: &http::Uri,
client_cert_der: Option<&[u8]>,
) -> ValidationResult<Claims, IntrospectionValidateError> {
let (dpop_nonce, outcome) = self
.validate_inner(headers, http_method, http_uri, client_cert_der)
.await;
if let Some(cb) = &self.on_validate {
use crate::introspection::IntrospectionCallError;
let validation_outcome = match &outcome {
Ok(Some(_)) => ValidationOutcome::Success,
Ok(None) => ValidationOutcome::NoToken,
Err(IntrospectionValidateError::Extract { .. }) => ValidationOutcome::ExtractError,
Err(IntrospectionValidateError::Binding { .. }) => ValidationOutcome::BindingError,
Err(IntrospectionValidateError::Audience { .. }) => ValidationOutcome::InvalidToken,
Err(IntrospectionValidateError::Call { source, .. }) => {
if matches!(source, IntrospectionCallError::TokenInactive) {
ValidationOutcome::InvalidToken
} else {
ValidationOutcome::CallError
}
}
};
cb.on_validate(validation_outcome);
}
ValidationResult {
outcome,
dpop_nonce,
}
}
async fn validate_inner(
&self,
headers: &http::HeaderMap,
http_method: &http::Method,
http_uri: &http::Uri,
client_cert_der: Option<&[u8]>,
) -> (
Option<String>,
Result<Option<ValidatedRequest<Claims>>, IntrospectionValidateError>,
) {
let (token_type, access_token) =
match extract_token(headers, &self.token_header).context(ExtractSnafu) {
Err(e) => return (None, Err(e)),
Ok(None) => return (None, Ok(None)),
Ok(Some(v)) => v,
};
let validated = match self
.token_introspection
.introspect::<_, Claims>(&self.http_client, &access_token)
.await
.context(CallSnafu { token_type })
{
Err(e) => return (None, Err(e)),
Ok(v) => v,
};
if let Err(expected) = check_audience(&self.audience, &validated.audience) {
return (
None,
Err(AudienceSnafu {
token_type,
expected,
actual: validated.audience.clone(),
}
.build()),
);
}
let (dpop_nonce, binding_result) = check_token_binding(
token_type,
validated.cnf.as_ref(),
&access_token,
&self.dpop_binding_checker,
self.require_mtls,
headers,
http_method,
http_uri,
client_cert_der,
)
.await;
let outcome = binding_result
.context(BindingSnafu { token_type })
.map(|()| Some(validated));
(dpop_nonce, outcome)
}
}
impl<Claims: for<'de> Deserialize<'de> + Clone + 'static> ProvideValidatorMetadata
for IntrospectionValidator<Claims>
{
fn validator_metadata(&self, resource: Option<&str>) -> ValidatorMetadata {
self.validator_metadata(resource)
}
}
fn check_audience(check: &ClaimCheck, aud: &[String]) -> Result<(), String> {
let ok = match check {
ClaimCheck::NoCheck => true,
ClaimCheck::Present => !aud.is_empty(),
ClaimCheck::RequiredValue(v) => aud.contains(v),
ClaimCheck::RequireAny(vs) => vs.iter().any(|v| aud.contains(v)),
ClaimCheck::IfPresent(v) => aud.is_empty() || aud.contains(v),
_ => false,
};
if ok {
return Ok(());
}
Err(match check {
ClaimCheck::Present => "any audience".to_owned(),
ClaimCheck::RequiredValue(v) | ClaimCheck::IfPresent(v) => v.clone(),
ClaimCheck::RequireAny(vs) => vs.join(" or "),
_ => "a supported audience check".to_owned(),
})
}
impl<Claims: for<'de> Deserialize<'de> + Clone + MaybeSendSync + 'static> AccessTokenValidator
for IntrospectionValidator<Claims>
{
type Claims = Claims;
type Error = IntrospectionValidateError;
fn validate_request<'a>(
&'a self,
headers: &'a http::HeaderMap,
method: &'a http::Method,
uri: &'a http::Uri,
client_cert_der: Option<&'a [u8]>,
) -> crate::core::platform::MaybeSendBoxFuture<'a, ValidationResult<Self::Claims, Self::Error>>
{
Box::pin(self.validate_request(headers, method, uri, client_cert_der))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn auds(values: &[&str]) -> Vec<String> {
values.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn audience_no_check_accepts_anything() {
assert!(check_audience(&ClaimCheck::NoCheck, &[]).is_ok());
assert!(check_audience(&ClaimCheck::NoCheck, &auds(&["other"])).is_ok());
}
#[test]
fn audience_required_value() {
let check = ClaimCheck::required_value("api://rs1");
assert!(check_audience(&check, &auds(&["api://rs1"])).is_ok());
assert!(check_audience(&check, &auds(&["api://rs2", "api://rs1"])).is_ok());
assert!(check_audience(&check, &auds(&["api://rs2"])).is_err());
assert!(check_audience(&check, &[]).is_err());
}
#[test]
fn audience_require_any() {
let check = ClaimCheck::require_any(["api://a", "api://b"]);
assert!(check_audience(&check, &auds(&["api://b"])).is_ok());
assert!(check_audience(&check, &auds(&["api://c"])).is_err());
assert!(check_audience(&check, &[]).is_err());
}
#[test]
fn audience_if_present() {
let check = ClaimCheck::if_present("api://rs1");
assert!(check_audience(&check, &[]).is_ok());
assert!(check_audience(&check, &auds(&["api://rs1"])).is_ok());
assert!(check_audience(&check, &auds(&["api://rs2"])).is_err());
}
#[test]
fn audience_present() {
assert!(check_audience(&ClaimCheck::present(), &auds(&["anything"])).is_ok());
assert!(check_audience(&ClaimCheck::present(), &[]).is_err());
}
}