use crate::basic::BasicErrorResponseType;
use crate::endpoint::{endpoint_request, endpoint_response_status_only};
use crate::{
AccessToken, AsyncHttpClient, AuthType, Client, ClientId, ClientSecret, ConfigurationError,
EndpointState, ErrorResponse, ErrorResponseType, HttpRequest, RefreshToken, RequestTokenError,
RevocationUrl, SyncHttpClient, TokenIntrospectionResponse, TokenResponse,
};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::error::Error;
use std::fmt::Error as FormatterError;
use std::fmt::{Debug, Display, Formatter};
use std::future::Future;
use std::marker::PhantomData;
impl<
TE,
TR,
TIR,
RT,
TRE,
HasAuthUrl,
HasDeviceAuthUrl,
HasIntrospectionUrl,
HasRevocationUrl,
HasTokenUrl,
>
Client<
TE,
TR,
TIR,
RT,
TRE,
HasAuthUrl,
HasDeviceAuthUrl,
HasIntrospectionUrl,
HasRevocationUrl,
HasTokenUrl,
>
where
TE: ErrorResponse + 'static,
TR: TokenResponse,
TIR: TokenIntrospectionResponse,
RT: RevocableToken,
TRE: ErrorResponse + 'static,
HasAuthUrl: EndpointState,
HasDeviceAuthUrl: EndpointState,
HasIntrospectionUrl: EndpointState,
HasRevocationUrl: EndpointState,
HasTokenUrl: EndpointState,
{
pub(crate) fn revoke_token_impl<'a>(
&'a self,
revocation_url: &'a RevocationUrl,
token: RT,
) -> Result<RevocationRequest<'a, RT, TRE>, ConfigurationError> {
if revocation_url.url().scheme() != "https" {
return Err(ConfigurationError::InsecureUrl("revocation"));
}
Ok(RevocationRequest {
auth_type: &self.auth_type,
client_id: &self.client_id,
client_secret: self.client_secret.as_ref(),
extra_params: Vec::new(),
revocation_url,
token,
_phantom: PhantomData,
})
}
}
pub trait RevocableToken {
fn secret(&self) -> &str;
fn type_hint(&self) -> Option<&str>;
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub enum StandardRevocableToken {
AccessToken(AccessToken),
RefreshToken(RefreshToken),
}
impl RevocableToken for StandardRevocableToken {
fn secret(&self) -> &str {
match self {
Self::AccessToken(token) => token.secret(),
Self::RefreshToken(token) => token.secret(),
}
}
fn type_hint(&self) -> Option<&str> {
match self {
StandardRevocableToken::AccessToken(_) => Some("access_token"),
StandardRevocableToken::RefreshToken(_) => Some("refresh_token"),
}
}
}
impl From<AccessToken> for StandardRevocableToken {
fn from(token: AccessToken) -> Self {
Self::AccessToken(token)
}
}
impl From<&AccessToken> for StandardRevocableToken {
fn from(token: &AccessToken) -> Self {
Self::AccessToken(token.clone())
}
}
impl From<RefreshToken> for StandardRevocableToken {
fn from(token: RefreshToken) -> Self {
Self::RefreshToken(token)
}
}
impl From<&RefreshToken> for StandardRevocableToken {
fn from(token: &RefreshToken) -> Self {
Self::RefreshToken(token.clone())
}
}
#[derive(Debug)]
pub struct RevocationRequest<'a, RT, TE>
where
RT: RevocableToken,
TE: ErrorResponse,
{
pub(crate) token: RT,
pub(crate) auth_type: &'a AuthType,
pub(crate) client_id: &'a ClientId,
pub(crate) client_secret: Option<&'a ClientSecret>,
pub(crate) extra_params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
pub(crate) revocation_url: &'a RevocationUrl,
pub(crate) _phantom: PhantomData<(RT, TE)>,
}
impl<'a, RT, TE> RevocationRequest<'a, RT, TE>
where
RT: RevocableToken,
TE: ErrorResponse + 'static,
{
pub fn add_extra_param<N, V>(mut self, name: N, value: V) -> Self
where
N: Into<Cow<'a, str>>,
V: Into<Cow<'a, str>>,
{
self.extra_params.push((name.into(), value.into()));
self
}
fn prepare_request<RE>(self) -> Result<HttpRequest, RequestTokenError<RE, TE>>
where
RE: Error + 'static,
{
let mut params: Vec<(&str, &str)> = vec![("token", self.token.secret())];
if let Some(type_hint) = self.token.type_hint() {
params.push(("token_type_hint", type_hint));
}
endpoint_request(
self.auth_type,
self.client_id,
self.client_secret,
&self.extra_params,
None,
None,
self.revocation_url.url(),
params,
)
.map_err(|err| RequestTokenError::Other(format!("failed to prepare request: {err}")))
}
pub fn request<C>(
self,
http_client: &C,
) -> Result<(), RequestTokenError<<C as SyncHttpClient>::Error, TE>>
where
C: SyncHttpClient,
{
endpoint_response_status_only(http_client.call(self.prepare_request()?)?)
}
pub fn request_async<'c, C>(
self,
http_client: &'c C,
) -> impl Future<Output = Result<(), RequestTokenError<<C as AsyncHttpClient<'c>>::Error, TE>>> + 'c
where
Self: 'c,
C: AsyncHttpClient<'c>,
{
Box::pin(async move {
endpoint_response_status_only(http_client.call(self.prepare_request()?).await?)
})
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum RevocationErrorResponseType {
UnsupportedTokenType,
Basic(BasicErrorResponseType),
}
impl RevocationErrorResponseType {
fn from_str(s: &str) -> Self {
match BasicErrorResponseType::from_str(s) {
BasicErrorResponseType::Extension(ext) => match ext.as_str() {
"unsupported_token_type" => RevocationErrorResponseType::UnsupportedTokenType,
_ => RevocationErrorResponseType::Basic(BasicErrorResponseType::Extension(ext)),
},
basic => RevocationErrorResponseType::Basic(basic),
}
}
}
impl AsRef<str> for RevocationErrorResponseType {
fn as_ref(&self) -> &str {
match self {
RevocationErrorResponseType::UnsupportedTokenType => "unsupported_token_type",
RevocationErrorResponseType::Basic(basic) => basic.as_ref(),
}
}
}
impl<'de> serde::Deserialize<'de> for RevocationErrorResponseType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
let variant_str = String::deserialize(deserializer)?;
Ok(Self::from_str(&variant_str))
}
}
impl serde::ser::Serialize for RevocationErrorResponseType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
serializer.serialize_str(self.as_ref())
}
}
impl ErrorResponseType for RevocationErrorResponseType {}
impl Debug for RevocationErrorResponseType {
fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> {
Display::fmt(self, f)
}
}
impl Display for RevocationErrorResponseType {
fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> {
write!(f, "{}", self.as_ref())
}
}
#[cfg(test)]
mod tests {
use crate::basic::BasicRevocationErrorResponse;
use crate::tests::colorful_extension::{ColorfulClient, ColorfulRevocableToken};
use crate::tests::{mock_http_client, new_client};
use crate::{
AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, RequestTokenError,
RevocationErrorResponseType, RevocationUrl, TokenUrl,
};
use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use http::{HeaderValue, Response, StatusCode};
#[test]
fn test_token_revocation_with_missing_url() {
let client = new_client().set_revocation_url_option(None);
let result = client
.revoke_token(AccessToken::new("access_token_123".to_string()).into())
.unwrap_err();
assert_eq!(result.to_string(), "No revocation endpoint URL specified");
}
#[test]
fn test_token_revocation_with_non_https_url() {
let client = new_client();
let result = client
.set_revocation_url(RevocationUrl::new("http://revocation/url".to_string()).unwrap())
.revoke_token(AccessToken::new("access_token_123".to_string()).into())
.unwrap_err();
assert_eq!(
result.to_string(),
"Scheme for revocation endpoint URL must be HTTPS"
);
}
#[test]
fn test_token_revocation_with_unsupported_token_type() {
let client = new_client()
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
let revocation_response = client
.revoke_token(AccessToken::new("access_token_123".to_string()).into()).unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=access_token_123&token_type_hint=access_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(
CONTENT_TYPE,
HeaderValue::from_str("application/json").unwrap(),
)
.body(
"{\
\"error\": \"unsupported_token_type\", \"error_description\": \"stuff happened\", \
\"error_uri\": \"https://errors\"\
}"
.to_string()
.into_bytes(),
)
.unwrap(),
));
assert!(matches!(
revocation_response,
Err(RequestTokenError::ServerResponse(
BasicRevocationErrorResponse {
error: RevocationErrorResponseType::UnsupportedTokenType,
..
}
))
));
}
#[test]
fn test_token_revocation_with_access_token_and_empty_json_response() {
let client = new_client()
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
client
.revoke_token(AccessToken::new("access_token_123".to_string()).into())
.unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=access_token_123&token_type_hint=access_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::OK)
.header(
CONTENT_TYPE,
HeaderValue::from_str("application/json").unwrap(),
)
.body(b"{}".to_vec())
.unwrap(),
))
.unwrap();
}
#[test]
fn test_token_revocation_with_access_token_and_empty_response() {
let client = new_client()
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
client
.revoke_token(AccessToken::new("access_token_123".to_string()).into())
.unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=access_token_123&token_type_hint=access_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::OK)
.body(vec![])
.unwrap(),
))
.unwrap();
}
#[test]
fn test_token_revocation_with_access_token_and_non_json_response() {
let client = new_client()
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
client
.revoke_token(AccessToken::new("access_token_123".to_string()).into())
.unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=access_token_123&token_type_hint=access_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::OK)
.header(
CONTENT_TYPE,
HeaderValue::from_str("application/octet-stream").unwrap(),
)
.body(vec![1, 2, 3])
.unwrap(),
))
.unwrap();
}
#[test]
fn test_token_revocation_with_refresh_token() {
let client = new_client()
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
client
.revoke_token(RefreshToken::new("refresh_token_123".to_string()).into())
.unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=refresh_token_123&token_type_hint=refresh_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::OK)
.header(
CONTENT_TYPE,
HeaderValue::from_str("application/json").unwrap(),
)
.body(b"{}".to_vec())
.unwrap(),
))
.unwrap();
}
#[test]
fn test_extension_token_revocation_successful() {
let client = ColorfulClient::new(ClientId::new("aaa".to_string()))
.set_client_secret(ClientSecret::new("bbb".to_string()))
.set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap())
.set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap())
.set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
client
.revoke_token(ColorfulRevocableToken::Red(
"colorful_token_123".to_string(),
))
.unwrap()
.request(&mock_http_client(
vec![
(ACCEPT, "application/json"),
(CONTENT_TYPE, "application/x-www-form-urlencoded"),
(AUTHORIZATION, "Basic YWFhOmJiYg=="),
],
"token=colorful_token_123&token_type_hint=red_token",
Some("https://revocation/url".parse().unwrap()),
Response::builder()
.status(StatusCode::OK)
.header(
CONTENT_TYPE,
HeaderValue::from_str("application/json").unwrap(),
)
.body(b"{}".to_vec())
.unwrap(),
))
.unwrap();
}
}