pub mod error;
mod map;
mod source;
use std::collections::HashMap;
use base64::prelude::*;
pub use error::MultiIssuerError;
use http::{HeaderName, header::AUTHORIZATION};
pub use map::MapClaims;
use serde::Deserialize;
use crate::{
AccessTokenValidator,
core::platform::{MaybeSendBoxFuture, MaybeSendSync},
error::ToRfc6750Error,
validator::{
ValidationResult,
extract::extract_token,
metadata::{ProvideValidatorMetadata, ValidatorMetadata},
multi_issuer::source::{FoldError, SourceValidator},
},
};
pub struct MultiIssuerValidator<C> {
by_issuer: HashMap<String, Box<dyn SourceValidator<C>>>,
metadata: ValidatorMetadata,
token_header: HeaderName,
}
#[bon::bon]
impl<C: MaybeSendSync + 'static> MultiIssuerValidator<C> {
#[builder]
pub fn new(
#[builder(field)]
sources: Vec<(String, Box<dyn SourceValidator<C>>)>,
#[builder(default = AUTHORIZATION)]
token_header: HeaderName,
) -> Self {
let metadata = union_metadata(&sources, None);
Self {
by_issuer: sources.into_iter().collect(),
metadata,
token_header,
}
}
}
impl<C: MaybeSendSync + 'static, S: multi_issuer_validator_builder::State>
MultiIssuerValidatorBuilder<C, S>
{
pub fn source<V>(mut self, issuer: impl Into<String>, validator: V) -> Self
where
V: AccessTokenValidator<Claims = C> + ProvideValidatorMetadata + 'static,
V::Error: ToRfc6750Error + 'static,
{
self.sources
.push((issuer.into(), Box::new(FoldError(validator))));
self
}
}
impl<C: MaybeSendSync + 'static> AccessTokenValidator for MultiIssuerValidator<C> {
type Claims = C;
type Error = MultiIssuerError;
fn validate_request<'a>(
&'a self,
headers: &'a http::HeaderMap,
method: &'a http::Method,
uri: &'a http::Uri,
client_cert_der: Option<&'a [u8]>,
) -> MaybeSendBoxFuture<'a, ValidationResult<C, MultiIssuerError>> {
Box::pin(async move {
let token = match extract_token(headers, &self.token_header) {
Ok(Some((_token_type, token))) => token,
Ok(None) => {
return ValidationResult {
outcome: Ok(None),
dpop_nonce: None,
};
}
Err(e) => {
return ValidationResult {
outcome: Err(MultiIssuerError::Extract { source: e }),
dpop_nonce: None,
};
}
};
let Some(validator) =
peek_issuer(token.expose_secret()).and_then(|iss| self.by_issuer.get(&iss))
else {
return ValidationResult {
outcome: Err(MultiIssuerError::UnrecognizedIssuer),
dpop_nonce: None,
};
};
validator
.validate_request(headers, method, uri, client_cert_der)
.await
})
}
}
impl<C> ProvideValidatorMetadata for MultiIssuerValidator<C> {
fn validator_metadata(&self, resource: Option<&str>) -> ValidatorMetadata {
ValidatorMetadata {
resource: resource.map(str::to_owned),
..self.metadata.clone()
}
}
}
fn peek_issuer(token: &str) -> Option<String> {
#[derive(Deserialize)]
struct IssOnly {
iss: String,
}
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
parts.next()?; if parts.next().is_some() {
return None; }
let bytes = BASE64_URL_SAFE_NO_PAD.decode(payload).ok()?;
serde_json::from_slice::<IssOnly>(&bytes)
.ok()
.map(|i| i.iss)
}
fn union_metadata<C>(
sources: &[(String, Box<dyn SourceValidator<C>>)],
resource: Option<&str>,
) -> ValidatorMetadata {
let mut authorization_servers = Vec::new();
let mut dpop_algs: Vec<String> = Vec::new();
let mut all_require_dpop = !sources.is_empty();
let mut any_dpop_supported = false;
let mut any_mtls_bound_supported = false;
let mut realm: Option<Option<String>> = None;
let mut resource_metadata: Option<Option<String>> = None;
for (_issuer, validator) in sources {
let m = validator.validator_metadata(resource);
realm = match realm {
None => Some(m.realm.clone()),
Some(r) if r == m.realm => Some(r),
Some(_) => Some(None),
};
resource_metadata = match resource_metadata {
None => Some(m.resource_metadata.clone()),
Some(r) if r == m.resource_metadata => Some(r),
Some(_) => Some(None),
};
any_dpop_supported |= m.supports_dpop();
any_mtls_bound_supported |= m.tls_client_certificate_bound_access_tokens == Some(true);
if let Some(servers) = m.authorization_servers {
authorization_servers.extend(servers);
}
if let Some(algs) = m.dpop_signing_alg_values_supported {
for alg in algs {
if !dpop_algs.contains(&alg) {
dpop_algs.push(alg);
}
}
}
all_require_dpop &= m.dpop_bound_access_tokens_required.unwrap_or(false);
}
ValidatorMetadata {
realm: realm.flatten(),
authorization_servers: (!authorization_servers.is_empty()).then_some(authorization_servers),
dpop_supported: Some(any_dpop_supported),
dpop_signing_alg_values_supported: (!dpop_algs.is_empty()).then_some(dpop_algs),
dpop_bound_access_tokens_required: Some(all_require_dpop),
tls_client_certificate_bound_access_tokens: any_mtls_bound_supported.then_some(true),
resource: resource.map(str::to_owned),
bearer_methods_supported: Some(vec!["header"]),
resource_metadata: resource_metadata.flatten(),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn seg(s: &str) -> String {
BASE64_URL_SAFE_NO_PAD.encode(s)
}
fn token_with_payload(payload_b64: &str) -> String {
format!("{}.{payload_b64}.{}", seg(r#"{"alg":"RS256"}"#), seg("sig"))
}
#[rstest]
#[case::standard(
token_with_payload(&seg(r#"{"iss":"https://issuer.example","sub":"abc"}"#)),
"https://issuer.example"
)]
#[case::empty_signature(
format!("{}.{}.", seg(r#"{"alg":"none"}"#), seg(r#"{"iss":"iss-a"}"#)),
"iss-a"
)]
fn reads_iss_from_unverified_payload(#[case] token: String, #[case] expected: &str) {
assert_eq!(peek_issuer(&token).as_deref(), Some(expected));
}
#[rstest]
#[case::single_segment("not-a-jwt".to_owned())]
#[case::two_segments(format!("{}.{}", seg(r#"{"alg":"none"}"#), seg(r#"{"iss":"iss-a"}"#)))]
#[case::four_segments(format!(
"{}.{}.{}.{}",
seg(r#"{"alg":"none"}"#),
seg(r#"{"iss":"iss-a"}"#),
seg("sig"),
seg("extra"),
))]
fn wrong_segment_count_is_rejected(#[case] token: String) {
assert_eq!(peek_issuer(&token), None);
}
#[rstest]
#[case::non_base64url("not!base64".to_owned())]
#[case::not_json(seg("this is not json"))]
#[case::no_iss(seg(r#"{"sub":"abc","aud":"api"}"#))]
#[case::non_string_iss(seg(r#"{"iss":42}"#))]
fn malformed_payload_is_rejected(#[case] payload_b64: String) {
assert_eq!(peek_issuer(&token_with_payload(&payload_b64)), None);
}
#[derive(Default)]
struct StubSource {
realm: Option<&'static str>,
resource_metadata: Option<&'static str>,
mtls_bound_supported: Option<bool>,
}
impl AccessTokenValidator for StubSource {
type Claims = ();
type Error = MultiIssuerError;
fn validate_request<'a>(
&'a self,
_headers: &'a http::HeaderMap,
_method: &'a http::Method,
_uri: &'a http::Uri,
_client_cert_der: Option<&'a [u8]>,
) -> MaybeSendBoxFuture<'a, ValidationResult<(), MultiIssuerError>> {
Box::pin(async {
ValidationResult {
outcome: Ok(None),
dpop_nonce: None,
}
})
}
}
impl ProvideValidatorMetadata for StubSource {
fn validator_metadata(&self, _resource: Option<&str>) -> ValidatorMetadata {
ValidatorMetadata::builder()
.maybe_realm(self.realm.map(str::to_owned))
.maybe_resource_metadata(self.resource_metadata.map(str::to_owned))
.maybe_tls_client_certificate_bound_access_tokens(self.mtls_bound_supported)
.build()
}
}
fn boxed_sources(
stubs: impl IntoIterator<Item = StubSource>,
) -> Vec<(String, Box<dyn SourceValidator<()>>)> {
stubs
.into_iter()
.enumerate()
.map(|(i, stub)| {
(
format!("https://issuer-{i}.example"),
Box::new(stub) as Box<dyn SourceValidator<()>>,
)
})
.collect()
}
fn stub_sources(
realms: &[Option<&'static str>],
) -> Vec<(String, Box<dyn SourceValidator<()>>)> {
boxed_sources(realms.iter().map(|realm| StubSource {
realm: *realm,
..StubSource::default()
}))
}
#[rstest]
#[case::all_agree(&[Some("api"), Some("api")], Some("api"))]
#[case::disagreement(&[Some("api"), Some("other")], None)]
#[case::partial(&[Some("api"), None], None)]
#[case::none_set(&[None, None], None)]
fn union_realm_requires_agreement(
#[case] realms: &[Option<&'static str>],
#[case] expected: Option<&str>,
) {
let meta = union_metadata(&stub_sources(realms), None);
assert_eq!(meta.realm.as_deref(), expected);
}
#[rstest]
#[case::all_agree(&[Some("https://api.example/prm"), Some("https://api.example/prm")], Some("https://api.example/prm"))]
#[case::disagreement(&[Some("https://api.example/prm"), Some("https://other.example/prm")], None)]
#[case::partial(&[Some("https://api.example/prm"), None], None)]
fn union_resource_metadata_requires_agreement(
#[case] urls: &[Option<&'static str>],
#[case] expected: Option<&str>,
) {
let sources = boxed_sources(urls.iter().map(|url| StubSource {
resource_metadata: *url,
..StubSource::default()
}));
assert_eq!(
union_metadata(&sources, None).resource_metadata.as_deref(),
expected
);
}
#[rstest]
#[case::any_true(&[None, Some(true)], Some(true))]
#[case::none_assert(&[None, Some(false)], None)]
fn union_mtls_bound_support_is_any(
#[case] flags: &[Option<bool>],
#[case] expected: Option<bool>,
) {
let sources = boxed_sources(flags.iter().map(|flag| StubSource {
mtls_bound_supported: *flag,
..StubSource::default()
}));
assert_eq!(
union_metadata(&sources, None).tls_client_certificate_bound_access_tokens,
expected
);
}
}