use std::sync::Arc;
use bon::Builder;
use serde::Serialize;
use crate::{
core::{
EndpointUrl,
client_auth::ClientAuthentication,
dpop::{AuthorizationServerDPoP, NoDPoP},
http::HttpClient,
secrets::SecretString,
},
grant::core::{OAuth2ExchangeGrant, mk_scopes},
token::RefreshToken,
};
#[huskarl_macros::from_metadata(metadata = crate::core::server_metadata::AuthorizationServerMetadata)]
#[derive(Clone, Builder)]
#[builder(on(String, into))]
pub struct RefreshGrant {
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 RefreshGrant {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("RefreshGrant")
.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 RefreshGrant {
type Parameters = RefreshGrantParameters;
type Form<'a> = RefreshGrantForm;
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 {
self.clone()
}
fn bound_dpop_jkt(params: &Self::Parameters) -> Option<&str> {
params.refresh_token.dpop_jkt()
}
fn build_form(&self, params: Self::Parameters) -> Self::Form<'_> {
RefreshGrantForm {
grant_type: "refresh_token",
refresh_token: params.refresh_token.token().clone(),
scope: params.scope,
resource: params.resource,
}
}
}
#[derive(Debug, Clone, Builder)]
pub struct RefreshGrantParameters {
refresh_token: RefreshToken,
#[builder(required, default, name = "scopes", with = |scopes: impl IntoIterator<Item = impl Into<String>>| mk_scopes(scopes))]
scope: Option<String>,
resource: Option<Vec<String>>,
}
impl RefreshGrantParameters {
#[must_use]
pub fn refresh_token(token: RefreshToken) -> Self {
Self::builder().refresh_token(token).build()
}
}
#[derive(Debug, Serialize)]
pub struct RefreshGrantForm {
grant_type: &'static str,
refresh_token: SecretString,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
resource: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refresh_form_serializes_token_as_plain_string() {
let form = RefreshGrantForm {
grant_type: "refresh_token",
refresh_token: SecretString::new("my-refresh-token"),
scope: None,
resource: None,
};
let encoded = serde_html_form::to_string(&form).unwrap();
assert_eq!(
encoded,
"grant_type=refresh_token&refresh_token=my-refresh-token"
);
}
#[test]
fn refresh_form_resource_serializes_as_repeated_keys() {
let form = RefreshGrantForm {
grant_type: "refresh_token",
refresh_token: SecretString::new("tok"),
scope: None,
resource: Some(vec![
"https://api.example.com".to_string(),
"https://other.example.com".to_string(),
]),
};
let encoded = serde_html_form::to_string(&form).unwrap();
assert!(
encoded.contains("resource=https%3A%2F%2Fapi.example.com"),
"first resource not found in: {encoded}"
);
assert!(
encoded.contains("resource=https%3A%2F%2Fother.example.com"),
"second resource not found in: {encoded}"
);
assert!(
!encoded.contains(','),
"resource values should not be comma-joined: {encoded}"
);
}
}