use std::{borrow::Cow, sync::Arc};
use bon::Builder;
use serde::Serialize;
use crate::{
core::{
EndpointUrl, Error,
client_auth::{AuthenticationContext, ClientAuthentication},
dpop::NoDPoP,
http::HttpClient,
},
grant::core::form::OAuth2FormRequest,
token::{AccessToken, RefreshToken},
};
pub trait RevocableToken {
fn token_value(&self) -> &str;
fn token_type_hint(&self) -> &'static str;
}
impl RevocableToken for AccessToken {
fn token_value(&self) -> &str {
self.token().expose_secret()
}
fn token_type_hint(&self) -> &'static str {
"access_token"
}
}
impl RevocableToken for RefreshToken {
fn token_value(&self) -> &str {
self.token().expose_secret()
}
fn token_type_hint(&self) -> &'static str {
"refresh_token"
}
}
#[huskarl_macros::from_metadata(metadata = crate::core::server_metadata::AuthorizationServerMetadata)]
#[derive(Clone, Builder)]
pub struct TokenRevocation {
#[builder(into)]
client_id: Cow<'static, str>,
#[builder(with = |auth: impl ClientAuthentication + 'static| Arc::new(auth) as Arc<dyn ClientAuthentication>)]
client_auth: Arc<dyn ClientAuthentication>,
#[builder(into)]
#[from_metadata(path = "issuer")]
issuer: Option<String>,
#[from_metadata(path = "token_endpoint")]
token_endpoint: Option<EndpointUrl>,
#[from_metadata(path = "revocation_endpoint?")]
revocation_endpoint: EndpointUrl,
#[from_metadata(path = "mtls_endpoint_aliases?.revocation_endpoint?")]
mtls_revocation_endpoint: Option<EndpointUrl>,
#[from_metadata(path = "revocation_endpoint_auth_methods_supported")]
revocation_endpoint_auth_methods_supported: Option<Vec<String>>,
}
impl core::fmt::Debug for TokenRevocation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("TokenRevocation")
.field("client_id", &self.client_id)
.field("issuer", &self.issuer)
.field("token_endpoint", &self.token_endpoint)
.field("revocation_endpoint", &self.revocation_endpoint)
.field("mtls_revocation_endpoint", &self.mtls_revocation_endpoint)
.finish_non_exhaustive()
}
}
impl TokenRevocation {
pub async fn revoke(
&self,
http_client: &impl HttpClient,
token: &impl RevocableToken,
) -> Result<(), Error> {
let effective_endpoint = if http_client.uses_mtls() {
self.mtls_revocation_endpoint
.as_ref()
.unwrap_or(&self.revocation_endpoint)
} else {
&self.revocation_endpoint
};
let auth_params = self
.client_auth
.authentication_context(
AuthenticationContext::builder()
.client_id(&self.client_id)
.target_endpoint(effective_endpoint)
.maybe_issuer(self.issuer.as_deref())
.maybe_token_endpoint(self.token_endpoint.as_ref())
.maybe_allowed_methods(
self.revocation_endpoint_auth_methods_supported.as_deref(),
)
.build(),
)
.await?;
let form = RevocationForm {
token: token.token_value(),
token_type_hint: token.token_type_hint(),
};
OAuth2FormRequest::builder()
.auth_params(auth_params)
.form(&form)
.uri(effective_endpoint.as_uri())
.dpop(&NoDPoP)
.build()
.execute_empty_response(http_client)
.await
}
}
#[derive(Debug, Serialize)]
struct RevocationForm<'a> {
token: &'a str,
token_type_hint: &'static str,
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use bytes::Bytes;
use http::{Request, StatusCode, Uri};
use rstest::rstest;
use super::*;
use crate::{
core::{
client_auth::NoAuth,
http::{HttpClient, HttpResponse, Idempotency},
platform::{MaybeSendBoxFuture, SystemTime},
secrets::SecretString,
},
token::{AccessToken, BearerAccessToken},
};
#[derive(Default)]
struct Captured {
uri: Option<Uri>,
body: Option<Bytes>,
}
struct RecordingClient {
status: StatusCode,
uses_mtls: bool,
captured: Mutex<Captured>,
}
impl RecordingClient {
fn new(status: StatusCode, uses_mtls: bool) -> Self {
Self {
status,
uses_mtls,
captured: Mutex::new(Captured::default()),
}
}
fn uri(&self) -> String {
self.captured
.lock()
.unwrap()
.uri
.clone()
.unwrap()
.to_string()
}
fn body(&self) -> String {
let bytes = self.captured.lock().unwrap().body.clone().unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
}
impl HttpClient for RecordingClient {
fn execute(
&self,
request: Request<Bytes>,
_idempotency: Idempotency,
) -> MaybeSendBoxFuture<'_, Result<HttpResponse, Error>> {
{
let mut cap = self.captured.lock().unwrap();
cap.uri = Some(request.uri().clone());
cap.body = Some(request.body().clone());
}
let status = self.status;
Box::pin(async move {
Ok(HttpResponse {
status,
headers: http::HeaderMap::new(),
body: Bytes::new(),
})
})
}
fn uses_mtls(&self) -> bool {
self.uses_mtls
}
}
fn url(s: &str) -> EndpointUrl {
s.parse().unwrap()
}
fn revocation(mtls_alias: Option<&str>) -> TokenRevocation {
TokenRevocation::builder()
.client_id("revoke-client")
.client_auth(NoAuth)
.revocation_endpoint(url("https://as.example/revoke"))
.maybe_mtls_revocation_endpoint(mtls_alias.map(url))
.build()
}
fn access_token() -> AccessToken {
AccessToken::Bearer(BearerAccessToken::new(
SecretString::new("the-access-token"),
SystemTime::now(),
None,
))
}
fn refresh_token() -> RefreshToken {
RefreshToken::new(SecretString::new("the-refresh-token"), None)
}
#[test]
fn revocable_token_impls_report_value_and_hint() {
assert_eq!(access_token().token_value(), "the-access-token");
assert_eq!(access_token().token_type_hint(), "access_token");
assert_eq!(refresh_token().token_value(), "the-refresh-token");
assert_eq!(refresh_token().token_type_hint(), "refresh_token");
}
#[tokio::test]
async fn revoke_access_token_posts_rfc7009_form_to_revocation_endpoint() {
let client = RecordingClient::new(StatusCode::OK, false);
revocation(None)
.revoke(&client, &access_token())
.await
.expect("200 with empty body is success");
assert_eq!(client.uri(), "https://as.example/revoke");
let body = client.body();
assert!(body.contains("token=the-access-token"), "body: {body}");
assert!(
body.contains("token_type_hint=access_token"),
"body: {body}"
);
}
#[tokio::test]
async fn revoke_refresh_token_sends_refresh_hint() {
let client = RecordingClient::new(StatusCode::OK, false);
revocation(None)
.revoke(&client, &refresh_token())
.await
.unwrap();
let body = client.body();
assert!(body.contains("token=the-refresh-token"), "body: {body}");
assert!(
body.contains("token_type_hint=refresh_token"),
"body: {body}"
);
}
#[rstest]
#[case::mtls_prefers_alias(
true,
Some("https://mtls.as.example/revoke"),
"https://mtls.as.example/revoke"
)]
#[case::mtls_without_alias_falls_back(true, None, "https://as.example/revoke")]
#[case::plain_client_ignores_alias(
false,
Some("https://mtls.as.example/revoke"),
"https://as.example/revoke"
)]
#[tokio::test]
async fn revoke_selects_endpoint_by_mtls_and_alias(
#[case] uses_mtls: bool,
#[case] alias: Option<&str>,
#[case] expected_uri: &str,
) {
let client = RecordingClient::new(StatusCode::OK, uses_mtls);
revocation(alias)
.revoke(&client, &access_token())
.await
.unwrap();
assert_eq!(client.uri(), expected_uri);
}
#[tokio::test]
async fn revoke_propagates_a_server_error_status() {
let client = RecordingClient::new(StatusCode::BAD_REQUEST, false);
let result = revocation(None).revoke(&client, &access_token()).await;
assert!(
result.is_err(),
"a non-2xx response must surface as an error"
);
}
}