use std::sync::Arc;
use bon::Builder;
use serde::Serialize;
use crate::{
cache::GrantParametersSource,
core::{
EndpointUrl, Error,
client_auth::ClientAuthentication,
dpop::{AuthorizationServerDPoP, NoDPoP},
http::HttpClient,
platform::MaybeSendBoxFuture,
secrets::SecretString,
},
grant::{
core::{OAuth2ExchangeGrant, join_space},
refresh::RefreshGrant,
},
};
#[huskarl_macros::from_metadata(metadata = crate::core::server_metadata::AuthorizationServerMetadata)]
#[derive(Builder)]
#[builder(on(String, into))]
pub struct JwtBearerGrant {
client_id: Option<String>,
#[builder(with = |client: impl HttpClient + 'static| Arc::new(client) as Arc<dyn HttpClient>)]
http_client: Arc<dyn HttpClient>,
#[builder(with = |auth: impl ClientAuthentication + 'static| Arc::new(auth) as Arc<dyn ClientAuthentication>)]
client_auth: Option<Arc<dyn ClientAuthentication>>,
#[builder(
with = |dpop: impl AuthorizationServerDPoP + 'static| Arc::new(dpop) as Arc<dyn AuthorizationServerDPoP>,
default = Arc::new(NoDPoP),
)]
dpop: Arc<dyn AuthorizationServerDPoP>,
#[from_metadata(path = "issuer")]
issuer: Option<String>,
#[from_metadata(path = "token_endpoint")]
token_endpoint: EndpointUrl,
#[from_metadata(path = "mtls_endpoint_aliases?.token_endpoint?")]
mtls_token_endpoint: Option<EndpointUrl>,
#[builder(skip = crate::grant::core::resolve_mtls_alias(http_client.as_ref(), &token_endpoint, mtls_token_endpoint.as_ref()))]
effective_token_endpoint: EndpointUrl,
#[from_metadata(path = "token_endpoint_auth_methods_supported")]
token_endpoint_auth_methods_supported: Option<Vec<String>>,
}
impl core::fmt::Debug for JwtBearerGrant {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("JwtBearerGrant")
.field("client_id", &self.client_id)
.field("issuer", &self.issuer)
.field("token_endpoint", &self.token_endpoint)
.field("mtls_token_endpoint", &self.mtls_token_endpoint)
.finish_non_exhaustive()
}
}
impl OAuth2ExchangeGrant for JwtBearerGrant {
type Parameters = JwtBearerGrantParameters;
type Form<'a> = JwtBearerGrantForm;
fn client_id(&self) -> Option<&str> {
self.client_id.as_deref()
}
fn issuer(&self) -> Option<&str> {
self.issuer.as_deref()
}
fn client_auth(&self) -> Option<&dyn ClientAuthentication> {
self.client_auth.as_deref()
}
fn token_endpoint(&self) -> &EndpointUrl {
&self.token_endpoint
}
fn effective_token_endpoint(&self) -> &EndpointUrl {
&self.effective_token_endpoint
}
fn dpop(&self) -> &dyn AuthorizationServerDPoP {
self.dpop.as_ref()
}
fn http_client(&self) -> &dyn HttpClient {
self.http_client.as_ref()
}
fn allowed_auth_methods(&self) -> Option<&[String]> {
self.token_endpoint_auth_methods_supported.as_deref()
}
fn to_refresh_grant(&self) -> RefreshGrant {
RefreshGrant::builder()
.maybe_client_id(self.client_id.clone())
.maybe_issuer(self.issuer.clone())
.http_client(self.http_client.clone())
.maybe_client_auth(self.client_auth.clone())
.dpop(self.dpop.clone())
.token_endpoint(self.effective_token_endpoint.clone())
.maybe_token_endpoint_auth_methods_supported(
self.token_endpoint_auth_methods_supported.clone(),
)
.build()
}
fn build_form(&self, params: Self::Parameters) -> Self::Form<'_> {
JwtBearerGrantForm {
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: params.assertion,
scope: join_space(params.scope.as_deref()),
resource: params.resource,
authorization_details: params.authorization_details,
}
}
}
#[derive(Debug, Clone, Builder)]
pub struct JwtBearerGrantParameters {
#[builder(into)]
assertion: SecretString,
scope: Option<Vec<String>>,
resource: Option<Vec<String>>,
authorization_details: Option<Vec<crate::core::AuthorizationDetail>>,
}
impl GrantParametersSource<Self> for JwtBearerGrantParameters {
fn acquire(&self) -> MaybeSendBoxFuture<'_, Result<Option<Self>, Error>> {
let params = self.clone();
Box::pin(async move { Ok(Some(params)) })
}
fn discard_after_rejection(&self) -> bool {
true
}
}
#[derive(Debug, Serialize, Builder)]
pub struct JwtBearerGrantForm {
grant_type: &'static str,
assertion: SecretString,
scope: Option<String>,
resource: Option<Vec<String>>,
authorization_details: Option<Vec<crate::core::AuthorizationDetail>>,
}
#[cfg(test)]
#[cfg(not(target_family = "wasm"))]
mod tests {
use std::sync::LazyLock;
use httpmock::MockServer;
use huskarl_crypto_native::asymmetric::signer::{GenerateAlgorithm, PrivateKey};
use huskarl_reqwest::ReqwestClient;
use serde_json::json;
use crate::{
core::{client_auth::NoAuth, dpop::DPoP, secrets::SecretString},
grant::jwt_bearer::{JwtBearerGrant, JwtBearerGrantParameters},
token::AccessToken,
};
static MOCK_SERVER: LazyLock<MockServer> = LazyLock::new(MockServer::start);
fn http_client() -> ReqwestClient {
reqwest::Client::new().into()
}
#[test]
fn test_assertion_setter_accepts_str_string_and_secret() {
for assertion in [
JwtBearerGrantParameters::builder()
.assertion("a.b.c")
.build(),
JwtBearerGrantParameters::builder()
.assertion(String::from("a.b.c"))
.build(),
JwtBearerGrantParameters::builder()
.assertion(SecretString::new("a.b.c"))
.build(),
] {
assert_eq!(assertion.assertion.expose_secret(), "a.b.c");
}
}
#[test]
fn test_form_serializes_grant_type_and_assertion() {
let form = super::JwtBearerGrantForm::builder()
.grant_type("urn:ietf:params:oauth:grant-type:jwt-bearer")
.assertion(SecretString::new("header.payload.signature"))
.build();
let encoded = crate::core::oauth_form::to_string(&form).unwrap();
assert!(
encoded.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer"),
"grant_type not found in: {encoded}"
);
assert!(
encoded.contains("assertion=header.payload.signature"),
"assertion not found in: {encoded}"
);
assert!(
!encoded.contains("scope="),
"scope should be omitted: {encoded}"
);
}
#[tokio::test]
async fn test_exchange() {
use httpmock::prelude::*;
use crate::prelude::*;
let grant = JwtBearerGrant::builder()
.token_endpoint(MOCK_SERVER.url("/no_dpop/token").parse().unwrap())
.client_id("client")
.http_client(http_client())
.client_auth(NoAuth)
.build();
let mock = MOCK_SERVER
.mock_async(|when, then| {
when.method(POST)
.path("/no_dpop/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.header_missing("DPoP")
.form_urlencoded_tuple(
"grant_type",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
)
.form_urlencoded_tuple("assertion", "the.signed.assertion")
.form_urlencoded_tuple("client_id", "client");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"access_token": "access_token",
"token_type": "Bearer",
}));
})
.await;
let response = grant
.exchange(
JwtBearerGrantParameters::builder()
.assertion("the.signed.assertion")
.build(),
)
.await;
mock.assert();
let response = response.unwrap();
assert!(matches!(response.access_token(), AccessToken::Bearer(_)));
assert_eq!(
response.access_token().token().expose_secret(),
"access_token"
);
}
#[tokio::test]
async fn test_exchange_anonymous_sends_no_client_id_or_auth() {
use httpmock::prelude::*;
use crate::prelude::*;
let grant = JwtBearerGrant::builder()
.token_endpoint(MOCK_SERVER.url("/anon/token").parse().unwrap())
.http_client(http_client())
.build();
let mock = MOCK_SERVER
.mock_async(|when, then| {
when.method(POST)
.path("/anon/token")
.form_urlencoded_tuple(
"grant_type",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
)
.form_urlencoded_tuple("assertion", "the.signed.assertion")
.form_urlencoded_tuple_missing("client_id")
.form_urlencoded_tuple_missing("client_secret")
.header_missing("Authorization");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"access_token": "access_token",
"token_type": "Bearer",
}));
})
.await;
let response = grant
.exchange(
JwtBearerGrantParameters::builder()
.assertion("the.signed.assertion")
.build(),
)
.await;
mock.assert();
assert!(response.is_ok());
}
#[tokio::test]
async fn test_exchange_with_dpop() {
use httpmock::prelude::*;
use crate::prelude::*;
let grant = JwtBearerGrant::builder()
.token_endpoint(MOCK_SERVER.url("/with_dpop/token").parse().unwrap())
.client_id("client")
.http_client(http_client())
.client_auth(NoAuth)
.dpop(
DPoP::builder()
.signer(PrivateKey::generate(GenerateAlgorithm::Es256, None).unwrap())
.build(),
)
.build();
let mock = MOCK_SERVER
.mock_async(|when, then| {
when.method(POST)
.path("/with_dpop/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.header_exists("DPoP")
.form_urlencoded_tuple(
"grant_type",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
)
.form_urlencoded_tuple("assertion", "the.signed.assertion");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json!({
"access_token": "access_token",
"token_type": "DPoP",
}));
})
.await;
let response = grant
.exchange(
JwtBearerGrantParameters::builder()
.assertion("the.signed.assertion")
.build(),
)
.await;
mock.assert();
let response = response.unwrap();
assert!(matches!(response.access_token(), AccessToken::DPoP(_)));
assert_eq!(
response.access_token().token().expose_secret(),
"access_token"
);
}
}