use std::{sync::Arc, time::Duration};
use bon::Builder;
use snafu::Snafu;
use crate::{
EndpointUrl,
client_auth::{AuthenticationParams, ClientAuthentication},
crypto::signer::JwsSignerSelector,
error::{Error, ErrorKind},
jwt::Jwt,
platform::MaybeSendBoxFuture,
};
#[derive(Debug, Clone, Builder)]
pub struct JwtBearer {
#[builder(with = |signer: impl JwsSignerSelector + 'static| Arc::new(signer) as Arc<dyn JwsSignerSelector>)]
signer: Arc<dyn JwsSignerSelector>,
#[builder(into)]
subject: Option<String>,
audience: Audience,
#[builder(default = Duration::from_mins(1))]
expires_after: Duration,
#[builder(default = true)]
explicit_typ: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Audience {
Issuer,
TokenEndpoint,
TargetEndpoint,
Custom(Arc<str>),
}
#[derive(Debug, Clone, Copy, Default, Snafu)]
#[snafu(display(
"Audience::Issuer requires an issuer; fetch authorization server metadata \
or set an issuer explicitly"
))]
pub struct MissingIssuer;
#[derive(Debug, Clone, Copy, Default, Snafu)]
#[snafu(display(
"Audience::TokenEndpoint requires the authorization server's token endpoint, \
but none was available; use Audience::TargetEndpoint or Audience::Issuer, \
or configure the token endpoint"
))]
pub struct MissingTokenEndpoint;
impl ClientAuthentication for JwtBearer {
fn authentication_params<'a>(
&'a self,
client_id: &'a str,
issuer: Option<&'a str>,
token_endpoint: Option<&'a EndpointUrl>,
target_endpoint: &'a EndpointUrl,
_allowed_methods: Option<&'a [String]>,
) -> MaybeSendBoxFuture<'a, Result<AuthenticationParams<'a>, Error>> {
Box::pin(async move {
let audience = match &self.audience {
Audience::Issuer => issuer
.ok_or_else(|| Error::new(ErrorKind::Config, MissingIssuer))?
.to_string(),
Audience::TokenEndpoint => token_endpoint
.ok_or_else(|| Error::new(ErrorKind::Config, MissingTokenEndpoint))?
.as_uri()
.to_string(),
Audience::TargetEndpoint => target_endpoint.as_uri().to_string(),
Audience::Custom(custom) => custom.to_string(),
};
let jwt = Jwt::builder()
.typ(if self.explicit_typ {
"client-authentication+jwt"
} else {
"JWT"
})
.audience(audience)
.issuer(client_id)
.subject(self.subject.as_deref().unwrap_or(client_id))
.issued_now_expires_after(self.expires_after)
.claims(())
.build();
let assertion = jwt
.to_jws_compact(&*self.signer.select_signer())
.await
.map_err(|err| err.with_context("signing client assertion JWT"))?;
Ok(AuthenticationParams::builder()
.form_params(bon::map! {
"client_id": client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion
})
.build())
})
}
}
#[cfg(test)]
mod tests {
use std::sync::LazyLock;
use base64::Engine as _;
use super::*;
use crate::{
client_auth::{ClientAuthentication, FormValue},
crypto::signer::JwsSigner,
};
#[derive(Debug, Clone)]
struct MockJwsSigner {
alg: &'static str,
}
impl JwsSigner for MockJwsSigner {
fn jws_algorithm(&self) -> std::borrow::Cow<'_, str> {
self.alg.into()
}
fn key_id(&self) -> Option<std::borrow::Cow<'_, str>> {
None
}
fn sign<'a>(&'a self, _input: &'a [u8]) -> MaybeSendBoxFuture<'a, Result<Vec<u8>, Error>> {
Box::pin(async { Ok(vec![0xAB]) })
}
}
impl JwsSignerSelector for MockJwsSigner {
fn select_signer(&self) -> Arc<dyn JwsSigner> {
Arc::new(self.clone())
}
}
fn extract_form_str(form: &[(&'static str, FormValue<'_>)], key: &str) -> String {
form.iter().find(|(k, _)| *k == key).map_or_else(
|| unreachable!("key {key} not found in form params"),
|(_, v)| match v {
FormValue::NonSensitive(c) => c.to_string(),
FormValue::Sensitive(c) => c.expose_secret().to_string(),
},
)
}
fn decode_claims(assertion: &str) -> serde_json::Value {
let parts: Vec<&str> = assertion.split('.').collect();
assert_eq!(parts.len(), 3);
serde_json::from_slice(
&base64::prelude::BASE64_URL_SAFE_NO_PAD
.decode(parts[1])
.unwrap(),
)
.unwrap()
}
fn decode_header(assertion: &str) -> serde_json::Value {
let parts: Vec<&str> = assertion.split('.').collect();
serde_json::from_slice(
&base64::prelude::BASE64_URL_SAFE_NO_PAD
.decode(parts[0])
.unwrap(),
)
.unwrap()
}
fn token_endpoint() -> &'static EndpointUrl {
static E: LazyLock<EndpointUrl> =
LazyLock::new(|| "https://token.example.com/token".parse().unwrap());
LazyLock::force(&E)
}
fn target_endpoint() -> &'static EndpointUrl {
static E: LazyLock<EndpointUrl> =
LazyLock::new(|| "https://as.example.com/revoke".parse().unwrap());
LazyLock::force(&E)
}
#[tokio::test]
async fn audience_issuer_with_issuer() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::Issuer)
.build();
let params = bearer
.authentication_params(
"cid",
Some("https://issuer.example.com"),
Some(token_endpoint()),
target_endpoint(),
None,
)
.await
.unwrap();
let form = params.form_params.unwrap();
let claims = decode_claims(&extract_form_str(&form, "client_assertion"));
assert_eq!(claims["aud"], "https://issuer.example.com");
}
#[tokio::test]
async fn audience_issuer_without_issuer_fails_closed() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::Issuer)
.build();
let err = bearer
.authentication_params("cid", None, Some(token_endpoint()), target_endpoint(), None)
.await
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Config);
assert!(
std::error::Error::source(&err)
.expect("carries a source")
.downcast_ref::<MissingIssuer>()
.is_some()
);
}
#[tokio::test]
async fn assertion_is_explicitly_typed_by_default() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TargetEndpoint)
.build();
let params = bearer
.authentication_params("cid", None, None, target_endpoint(), None)
.await
.unwrap();
let form = params.form_params.unwrap();
let header = decode_header(&extract_form_str(&form, "client_assertion"));
assert_eq!(header["typ"], "client-authentication+jwt");
}
#[tokio::test]
async fn explicit_typ_opt_out_restores_plain_jwt() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TargetEndpoint)
.explicit_typ(false)
.build();
let params = bearer
.authentication_params("cid", None, None, target_endpoint(), None)
.await
.unwrap();
let form = params.form_params.unwrap();
let header = decode_header(&extract_form_str(&form, "client_assertion"));
assert_eq!(header["typ"], "JWT");
}
#[tokio::test]
async fn audience_target_endpoint_uses_target_not_token() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TargetEndpoint)
.build();
let params = bearer
.authentication_params(
"cid",
Some("https://issuer.example.com"),
Some(token_endpoint()),
target_endpoint(),
None,
)
.await
.unwrap();
let form = params.form_params.unwrap();
let claims = decode_claims(&extract_form_str(&form, "client_assertion"));
assert_eq!(claims["aud"], "https://as.example.com/revoke");
}
#[tokio::test]
async fn audience_token_endpoint_uses_token_not_target() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TokenEndpoint)
.build();
let params = bearer
.authentication_params(
"cid",
Some("https://issuer.example.com"),
Some(token_endpoint()),
target_endpoint(),
None,
)
.await
.unwrap();
let form = params.form_params.unwrap();
let claims = decode_claims(&extract_form_str(&form, "client_assertion"));
assert_eq!(claims["aud"], "https://token.example.com/token");
}
#[tokio::test]
async fn audience_token_endpoint_without_token_endpoint_fails_closed() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TokenEndpoint)
.build();
let err = bearer
.authentication_params(
"cid",
Some("https://issuer.example.com"),
None,
target_endpoint(),
None,
)
.await
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::Config);
assert!(
std::error::Error::source(&err)
.expect("carries a source")
.downcast_ref::<MissingTokenEndpoint>()
.is_some()
);
}
#[tokio::test]
async fn audience_custom() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::Custom("https://custom.example.com".into()))
.build();
let params = bearer
.authentication_params(
"cid",
Some("https://issuer.example.com"),
Some(token_endpoint()),
target_endpoint(),
None,
)
.await
.unwrap();
let form = params.form_params.unwrap();
let claims = decode_claims(&extract_form_str(&form, "client_assertion"));
assert_eq!(claims["aud"], "https://custom.example.com");
}
#[tokio::test]
async fn form_params_structure() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TargetEndpoint)
.build();
let params = bearer
.authentication_params("my-client", None, None, target_endpoint(), None)
.await
.unwrap();
let form = params.form_params.unwrap();
assert_eq!(extract_form_str(&form, "client_id"), "my-client");
assert_eq!(
extract_form_str(&form, "client_assertion_type"),
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
);
let assertion = extract_form_str(&form, "client_assertion");
assert_eq!(assertion.split('.').count(), 3);
}
#[tokio::test]
async fn subject_override() {
let bearer = JwtBearer::builder()
.signer(MockJwsSigner { alg: "ES256" })
.audience(Audience::TargetEndpoint)
.subject("custom-subject")
.build();
let params = bearer
.authentication_params("my-client", None, None, target_endpoint(), None)
.await
.unwrap();
let form = params.form_params.unwrap();
let claims = decode_claims(&extract_form_str(&form, "client_assertion"));
assert_eq!(claims["iss"], "my-client");
assert_eq!(claims["sub"], "custom-subject");
}
}