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 TokenExchangeGrant {
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 TokenExchangeGrant {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("TokenExchangeGrant")
.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 TokenExchangeGrant {
type Parameters = TokenExchangeGrantParameters;
type Form<'a> = TokenExchangeGrantForm;
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<'_> {
TokenExchangeGrantForm {
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
resource: params.resource,
authorization_details: params.authorization_details,
audience: params.audience,
scope: join_space(params.scope.as_deref()),
requested_token_type: params.requested_token_type,
subject_token: params.subject.token,
subject_token_type: params.subject.token_type,
actor_token: params.actor.as_ref().map(|t| t.token.clone()),
actor_token_type: params.actor.as_ref().map(|t| t.token_type.clone()),
}
}
}
#[derive(Debug, Clone, Builder)]
#[builder(on(String, into), on(SecurityToken, into))]
pub struct TokenExchangeGrantParameters {
subject: SecurityToken,
resource: Option<Vec<String>>,
authorization_details: Option<Vec<crate::core::AuthorizationDetail>>,
audience: Option<String>,
scope: Option<Vec<String>>,
requested_token_type: Option<String>,
actor: Option<SecurityToken>,
}
impl GrantParametersSource<Self> for TokenExchangeGrantParameters {
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, Clone, Builder)]
pub struct SecurityToken {
#[builder(into)]
token: SecretString,
token_type: SecurityTokenType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum SecurityTokenType {
#[serde(rename = "urn:ietf:params:oauth:token-type:access_token")]
AccessToken,
#[serde(rename = "urn:ietf:params:oauth:token-type:refresh_token")]
RefreshToken,
#[serde(rename = "urn:ietf:params:oauth:token-type:id_token")]
IdToken,
#[serde(rename = "urn:ietf:params:oauth:token-type:saml1")]
Saml1,
#[serde(rename = "urn:ietf:params:oauth:token-type:saml2")]
Saml2,
#[serde(rename = "urn:ietf:params:oauth:token-type:jwt")]
Jwt,
#[serde(untagged)]
Other(String),
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use http::Request;
use serde_json::json;
use super::*;
use crate::core::{
Error,
http::{HttpClient, HttpResponse, Idempotency},
platform::MaybeSendBoxFuture,
};
struct UnusedClient;
impl HttpClient for UnusedClient {
fn execute(
&self,
_request: Request<Bytes>,
_idempotency: Idempotency,
) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
Box::pin(async { unreachable!("build_form performs no HTTP request") })
}
}
fn grant() -> TokenExchangeGrant {
TokenExchangeGrant::builder()
.token_endpoint("https://as.example/token".parse::<EndpointUrl>().unwrap())
.client_id("exchange-client")
.http_client(UnusedClient)
.build()
}
fn subject() -> SecurityToken {
SecurityToken::builder()
.token("subject-tok")
.token_type(SecurityTokenType::AccessToken)
.build()
}
#[test]
fn build_form_maps_all_parameters_onto_the_wire() {
let params = TokenExchangeGrantParameters::builder()
.subject(subject())
.actor(
SecurityToken::builder()
.token("actor-tok")
.token_type(SecurityTokenType::Jwt)
.build(),
)
.audience("https://api.example")
.scope(bon::vec!["read", "write"])
.requested_token_type("urn:ietf:params:oauth:token-type:access_token")
.authorization_details(vec![
crate::core::AuthorizationDetail::builder("payment_initiation")
.with("actions", serde_json::json!(["initiate"]))
.build(),
])
.build();
let encoded = crate::core::oauth_form::to_string(&grant().build_form(params)).unwrap();
for expected in [
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
"subject_token=subject-tok",
"subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
"actor_token=actor-tok",
"actor_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt",
"audience=https%3A%2F%2Fapi.example",
"scope=read+write",
"requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
"authorization_details=%5B%7B",
] {
assert!(
encoded.contains(expected),
"missing {expected} in: {encoded}"
);
}
}
#[test]
fn build_form_omits_absent_optional_fields() {
let params = TokenExchangeGrantParameters::builder()
.subject(subject())
.scope(vec![])
.build();
let encoded = crate::core::oauth_form::to_string(&grant().build_form(params)).unwrap();
assert!(encoded.contains("grant_type="));
assert!(encoded.contains("subject_token=subject-tok"));
for absent in [
"resource",
"audience",
"scope",
"requested_token_type",
"actor_token",
"actor_token_type",
] {
assert!(
!encoded.contains(absent),
"{absent} should be omitted, but found it in: {encoded}"
);
}
}
#[test]
fn resource_serializes_as_repeated_keys() {
let params = TokenExchangeGrantParameters::builder()
.subject(subject())
.resource(vec![
"https://api.example.com".to_string(),
"https://other.example.com".to_string(),
])
.scope(vec![])
.build();
let encoded = crate::core::oauth_form::to_string(&grant().build_form(params)).unwrap();
assert!(encoded.contains("resource=https%3A%2F%2Fapi.example.com"));
assert!(encoded.contains("resource=https%3A%2F%2Fother.example.com"));
assert!(
!encoded.contains(','),
"resource must not be comma-joined: {encoded}"
);
}
#[test]
fn security_token_type_serializes_to_rfc8693_urns() {
for (ty, urn) in [
(
SecurityTokenType::AccessToken,
"urn:ietf:params:oauth:token-type:access_token",
),
(
SecurityTokenType::RefreshToken,
"urn:ietf:params:oauth:token-type:refresh_token",
),
(
SecurityTokenType::IdToken,
"urn:ietf:params:oauth:token-type:id_token",
),
(
SecurityTokenType::Saml1,
"urn:ietf:params:oauth:token-type:saml1",
),
(
SecurityTokenType::Saml2,
"urn:ietf:params:oauth:token-type:saml2",
),
(
SecurityTokenType::Jwt,
"urn:ietf:params:oauth:token-type:jwt",
),
] {
assert_eq!(serde_json::to_value(&ty).unwrap(), json!(urn));
}
assert_eq!(
serde_json::to_value(SecurityTokenType::Other(
"urn:example:custom-token".to_string()
))
.unwrap(),
json!("urn:example:custom-token")
);
}
}
#[derive(Debug, Serialize)]
pub struct TokenExchangeGrantForm {
grant_type: &'static str,
resource: Option<Vec<String>>,
authorization_details: Option<Vec<crate::core::AuthorizationDetail>>,
audience: Option<String>,
scope: Option<String>,
requested_token_type: Option<String>,
subject_token: SecretString,
subject_token_type: SecurityTokenType,
actor_token: Option<SecretString>,
actor_token_type: Option<SecurityTokenType>,
}