oauth2/
revocation.rs

1use crate::basic::BasicErrorResponseType;
2use crate::endpoint::{endpoint_request, endpoint_response_status_only};
3use crate::{
4    AccessToken, AsyncHttpClient, AuthType, Client, ClientId, ClientSecret, ConfigurationError,
5    EndpointState, ErrorResponse, ErrorResponseType, HttpRequest, RefreshToken, RequestTokenError,
6    RevocationUrl, SyncHttpClient, TokenIntrospectionResponse, TokenResponse,
7};
8
9use serde::{Deserialize, Serialize};
10
11use std::borrow::Cow;
12use std::error::Error;
13use std::fmt::Error as FormatterError;
14use std::fmt::{Debug, Display, Formatter};
15use std::future::Future;
16use std::marker::PhantomData;
17
18impl<
19        TE,
20        TR,
21        TIR,
22        RT,
23        TRE,
24        HasAuthUrl,
25        HasDeviceAuthUrl,
26        HasIntrospectionUrl,
27        HasRevocationUrl,
28        HasTokenUrl,
29    >
30    Client<
31        TE,
32        TR,
33        TIR,
34        RT,
35        TRE,
36        HasAuthUrl,
37        HasDeviceAuthUrl,
38        HasIntrospectionUrl,
39        HasRevocationUrl,
40        HasTokenUrl,
41    >
42where
43    TE: ErrorResponse + 'static,
44    TR: TokenResponse,
45    TIR: TokenIntrospectionResponse,
46    RT: RevocableToken,
47    TRE: ErrorResponse + 'static,
48    HasAuthUrl: EndpointState,
49    HasDeviceAuthUrl: EndpointState,
50    HasIntrospectionUrl: EndpointState,
51    HasRevocationUrl: EndpointState,
52    HasTokenUrl: EndpointState,
53{
54    pub(crate) fn revoke_token_impl<'a>(
55        &'a self,
56        revocation_url: &'a RevocationUrl,
57        token: RT,
58    ) -> Result<RevocationRequest<'a, RT, TRE>, ConfigurationError> {
59        // https://tools.ietf.org/html/rfc7009#section-2 states:
60        //   "The client requests the revocation of a particular token by making an
61        //    HTTP POST request to the token revocation endpoint URL.  This URL
62        //    MUST conform to the rules given in [RFC6749], Section 3.1.  Clients
63        //    MUST verify that the URL is an HTTPS URL."
64        if revocation_url.url().scheme() != "https" {
65            return Err(ConfigurationError::InsecureUrl("revocation"));
66        }
67
68        Ok(RevocationRequest {
69            auth_type: &self.auth_type,
70            client_id: &self.client_id,
71            client_secret: self.client_secret.as_ref(),
72            extra_params: Vec::new(),
73            revocation_url,
74            token,
75            _phantom: PhantomData,
76        })
77    }
78}
79
80/// A revocable token.
81///
82/// Implement this trait to indicate support for token revocation per [RFC 7009 OAuth 2.0 Token Revocation](https://tools.ietf.org/html/rfc7009#section-2.2).
83pub trait RevocableToken {
84    /// The actual token value to be revoked.
85    fn secret(&self) -> &str;
86
87    /// Indicates the type of the token being revoked, as defined by [RFC 7009, Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1).
88    ///
89    /// Implementations should return `Some(...)` values for token types that the target authorization servers are
90    /// expected to know (e.g. because they are registered in the [OAuth Token Type Hints Registry](https://tools.ietf.org/html/rfc7009#section-4.1.2))
91    /// so that they can potentially optimize their search for the token to be revoked.
92    fn type_hint(&self) -> Option<&str>;
93}
94
95/// A token representation usable with authorization servers that support [RFC 7009](https://tools.ietf.org/html/rfc7009) token revocation.
96///
97/// For use with [`revoke_token()`].
98///
99/// Automatically reports the correct RFC 7009 [`token_type_hint`](https://tools.ietf.org/html/rfc7009#section-2.1) value corresponding to the token type variant used, i.e.
100/// `access_token` for [`AccessToken`] and `secret_token` for [`RefreshToken`].
101///
102/// # Example
103///
104/// Per [RFC 7009, Section 2](https://tools.ietf.org/html/rfc7009#section-2) prefer revocation by refresh token which,
105/// if issued to the client, must be supported by the server, otherwise fallback to access token (which may or may not
106/// be supported by the server).
107///
108/// ```rust
109/// # use http::{Response, StatusCode};
110/// # use oauth2::{
111/// #     AccessToken, AuthUrl, ClientId, EmptyExtraTokenFields, HttpResponse, RequestTokenError,
112/// #     RevocationUrl, StandardRevocableToken, StandardTokenResponse, TokenResponse, TokenUrl,
113/// # };
114/// # use oauth2::basic::{BasicClient, BasicRequestTokenError, BasicTokenResponse, BasicTokenType};
115/// #
116/// # fn err_wrapper() -> Result<(), anyhow::Error> {
117/// #
118/// # let token_response = BasicTokenResponse::new(
119/// #   AccessToken::new("access".to_string()),
120/// #   BasicTokenType::Bearer,
121/// #   EmptyExtraTokenFields {},
122/// # );
123/// #
124/// # #[derive(Debug, thiserror::Error)]
125/// # enum FakeError {}
126/// #
127/// # let http_client = |_| -> Result<HttpResponse, BasicRequestTokenError<FakeError>> {
128/// #     Ok(Response::builder()
129/// #         .status(StatusCode::OK)
130/// #         .body(Vec::new())
131/// #         .unwrap())
132/// # };
133/// #
134/// let client = BasicClient::new(ClientId::new("aaa".to_string()))
135///     .set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap())
136///     .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap())
137///     // Be sure to set a revocation URL.
138///     .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
139///
140/// // ...
141///
142/// let token_to_revoke: StandardRevocableToken = match token_response.refresh_token() {
143///     Some(token) => token.into(),
144///     None => token_response.access_token().into(),
145/// };
146///
147/// client
148///     .revoke_token(token_to_revoke)?
149///     .request(&http_client)
150/// #   .unwrap();
151/// # Ok(())
152/// # }
153/// ```
154///
155/// [`revoke_token()`]: crate::Client::revoke_token()
156#[derive(Clone, Debug, Deserialize, Serialize)]
157#[non_exhaustive]
158pub enum StandardRevocableToken {
159    /// A representation of an [`AccessToken`] suitable for use with [`revoke_token()`](crate::Client::revoke_token()).
160    AccessToken(AccessToken),
161    /// A representation of an [`RefreshToken`] suitable for use with [`revoke_token()`](crate::Client::revoke_token()).
162    RefreshToken(RefreshToken),
163}
164impl RevocableToken for StandardRevocableToken {
165    fn secret(&self) -> &str {
166        match self {
167            Self::AccessToken(token) => token.secret(),
168            Self::RefreshToken(token) => token.secret(),
169        }
170    }
171
172    /// Indicates the type of the token to be revoked, as defined by [RFC 7009, Section 2.1](https://tools.ietf.org/html/rfc7009#section-2.1), i.e.:
173    ///
174    /// * `access_token`: An access token as defined in [RFC 6749,
175    ///   Section 1.4](https://tools.ietf.org/html/rfc6749#section-1.4)
176    ///
177    /// * `refresh_token`: A refresh token as defined in [RFC 6749,
178    ///   Section 1.5](https://tools.ietf.org/html/rfc6749#section-1.5)
179    fn type_hint(&self) -> Option<&str> {
180        match self {
181            StandardRevocableToken::AccessToken(_) => Some("access_token"),
182            StandardRevocableToken::RefreshToken(_) => Some("refresh_token"),
183        }
184    }
185}
186
187impl From<AccessToken> for StandardRevocableToken {
188    fn from(token: AccessToken) -> Self {
189        Self::AccessToken(token)
190    }
191}
192
193impl From<&AccessToken> for StandardRevocableToken {
194    fn from(token: &AccessToken) -> Self {
195        Self::AccessToken(token.clone())
196    }
197}
198
199impl From<RefreshToken> for StandardRevocableToken {
200    fn from(token: RefreshToken) -> Self {
201        Self::RefreshToken(token)
202    }
203}
204
205impl From<&RefreshToken> for StandardRevocableToken {
206    fn from(token: &RefreshToken) -> Self {
207        Self::RefreshToken(token.clone())
208    }
209}
210
211/// A request to revoke a token via an [`RFC 7009`](https://tools.ietf.org/html/rfc7009#section-2.1) compatible
212/// endpoint.
213#[derive(Debug)]
214pub struct RevocationRequest<'a, RT, TE>
215where
216    RT: RevocableToken,
217    TE: ErrorResponse,
218{
219    pub(crate) token: RT,
220    pub(crate) auth_type: &'a AuthType,
221    pub(crate) client_id: &'a ClientId,
222    pub(crate) client_secret: Option<&'a ClientSecret>,
223    pub(crate) extra_params: Vec<(Cow<'a, str>, Cow<'a, str>)>,
224    pub(crate) revocation_url: &'a RevocationUrl,
225    pub(crate) _phantom: PhantomData<(RT, TE)>,
226}
227
228impl<'a, RT, TE> RevocationRequest<'a, RT, TE>
229where
230    RT: RevocableToken,
231    TE: ErrorResponse + 'static,
232{
233    /// Appends an extra param to the token revocation request.
234    ///
235    /// This method allows extensions to be used without direct support from
236    /// this crate. If `name` conflicts with a parameter managed by this crate, the
237    /// behavior is undefined. In particular, do not set parameters defined by
238    /// [RFC 6749](https://tools.ietf.org/html/rfc6749) or
239    /// [RFC 7662](https://tools.ietf.org/html/rfc7662).
240    ///
241    /// # Security Warning
242    ///
243    /// Callers should follow the security recommendations for any OAuth2 extensions used with
244    /// this function, which are beyond the scope of
245    /// [RFC 6749](https://tools.ietf.org/html/rfc6749).
246    pub fn add_extra_param<N, V>(mut self, name: N, value: V) -> Self
247    where
248        N: Into<Cow<'a, str>>,
249        V: Into<Cow<'a, str>>,
250    {
251        self.extra_params.push((name.into(), value.into()));
252        self
253    }
254
255    fn prepare_request<RE>(self) -> Result<HttpRequest, RequestTokenError<RE, TE>>
256    where
257        RE: Error + 'static,
258    {
259        let mut params: Vec<(&str, &str)> = vec![("token", self.token.secret())];
260        if let Some(type_hint) = self.token.type_hint() {
261            params.push(("token_type_hint", type_hint));
262        }
263
264        endpoint_request(
265            self.auth_type,
266            self.client_id,
267            self.client_secret,
268            &self.extra_params,
269            None,
270            None,
271            self.revocation_url.url(),
272            params,
273        )
274        .map_err(|err| RequestTokenError::Other(format!("failed to prepare request: {err}")))
275    }
276
277    /// Synchronously sends the request to the authorization server and awaits a response.
278    ///
279    /// A successful response indicates that the server either revoked the token or the token was not known to the
280    /// server.
281    ///
282    /// Error [`UnsupportedTokenType`](RevocationErrorResponseType::UnsupportedTokenType) will be returned if the
283    /// type of token type given is not supported by the server.
284    pub fn request<C>(
285        self,
286        http_client: &C,
287    ) -> Result<(), RequestTokenError<<C as SyncHttpClient>::Error, TE>>
288    where
289        C: SyncHttpClient,
290    {
291        // From https://tools.ietf.org/html/rfc7009#section-2.2:
292        //   "The content of the response body is ignored by the client as all
293        //    necessary information is conveyed in the response code."
294        endpoint_response_status_only(http_client.call(self.prepare_request()?)?)
295    }
296
297    /// Asynchronously sends the request to the authorization server and returns a Future.
298    pub fn request_async<'c, C>(
299        self,
300        http_client: &'c C,
301    ) -> impl Future<Output = Result<(), RequestTokenError<<C as AsyncHttpClient<'c>>::Error, TE>>> + 'c
302    where
303        Self: 'c,
304        C: AsyncHttpClient<'c>,
305    {
306        Box::pin(async move {
307            endpoint_response_status_only(http_client.call(self.prepare_request()?).await?)
308        })
309    }
310}
311
312/// OAuth 2.0 Token Revocation error response types.
313///
314/// These error types are defined in
315/// [Section 2.2.1 of RFC 7009](https://tools.ietf.org/html/rfc7009#section-2.2.1) and
316/// [Section 5.2 of RFC 6749](https://tools.ietf.org/html/rfc8628#section-5.2)
317#[derive(Clone, PartialEq, Eq)]
318pub enum RevocationErrorResponseType {
319    /// The authorization server does not support the revocation of the presented token type.
320    UnsupportedTokenType,
321    /// The authorization server responded with some other error as defined [RFC 6749](https://tools.ietf.org/html/rfc6749) error.
322    Basic(BasicErrorResponseType),
323}
324impl RevocationErrorResponseType {
325    fn from_str(s: &str) -> Self {
326        match BasicErrorResponseType::from_str(s) {
327            BasicErrorResponseType::Extension(ext) => match ext.as_str() {
328                "unsupported_token_type" => RevocationErrorResponseType::UnsupportedTokenType,
329                _ => RevocationErrorResponseType::Basic(BasicErrorResponseType::Extension(ext)),
330            },
331            basic => RevocationErrorResponseType::Basic(basic),
332        }
333    }
334}
335impl AsRef<str> for RevocationErrorResponseType {
336    fn as_ref(&self) -> &str {
337        match self {
338            RevocationErrorResponseType::UnsupportedTokenType => "unsupported_token_type",
339            RevocationErrorResponseType::Basic(basic) => basic.as_ref(),
340        }
341    }
342}
343impl<'de> serde::Deserialize<'de> for RevocationErrorResponseType {
344    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
345    where
346        D: serde::de::Deserializer<'de>,
347    {
348        let variant_str = String::deserialize(deserializer)?;
349        Ok(Self::from_str(&variant_str))
350    }
351}
352impl serde::ser::Serialize for RevocationErrorResponseType {
353    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
354    where
355        S: serde::ser::Serializer,
356    {
357        serializer.serialize_str(self.as_ref())
358    }
359}
360impl ErrorResponseType for RevocationErrorResponseType {}
361impl Debug for RevocationErrorResponseType {
362    fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> {
363        Display::fmt(self, f)
364    }
365}
366
367impl Display for RevocationErrorResponseType {
368    fn fmt(&self, f: &mut Formatter) -> Result<(), FormatterError> {
369        write!(f, "{}", self.as_ref())
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use crate::basic::BasicRevocationErrorResponse;
376    use crate::tests::colorful_extension::{ColorfulClient, ColorfulRevocableToken};
377    use crate::tests::{mock_http_client, new_client};
378    use crate::{
379        AccessToken, AuthUrl, ClientId, ClientSecret, RefreshToken, RequestTokenError,
380        RevocationErrorResponseType, RevocationUrl, TokenUrl,
381    };
382
383    use http::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
384    use http::{HeaderValue, Response, StatusCode};
385
386    #[test]
387    fn test_token_revocation_with_missing_url() {
388        let client = new_client().set_revocation_url_option(None);
389
390        let result = client
391            .revoke_token(AccessToken::new("access_token_123".to_string()).into())
392            .unwrap_err();
393
394        assert_eq!(result.to_string(), "No revocation endpoint URL specified");
395    }
396
397    #[test]
398    fn test_token_revocation_with_non_https_url() {
399        let client = new_client();
400
401        let result = client
402            .set_revocation_url(RevocationUrl::new("http://revocation/url".to_string()).unwrap())
403            .revoke_token(AccessToken::new("access_token_123".to_string()).into())
404            .unwrap_err();
405
406        assert_eq!(
407            result.to_string(),
408            "Scheme for revocation endpoint URL must be HTTPS"
409        );
410    }
411
412    #[test]
413    fn test_token_revocation_with_unsupported_token_type() {
414        let client = new_client()
415            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
416
417        let revocation_response = client
418          .revoke_token(AccessToken::new("access_token_123".to_string()).into()).unwrap()
419          .request(&mock_http_client(
420              vec![
421                  (ACCEPT, "application/json"),
422                  (CONTENT_TYPE, "application/x-www-form-urlencoded"),
423                  (AUTHORIZATION, "Basic YWFhOmJiYg=="),
424              ],
425              "token=access_token_123&token_type_hint=access_token",
426              Some("https://revocation/url".parse().unwrap()),
427              Response::builder()
428                .status(StatusCode::BAD_REQUEST)
429                .header(
430                    CONTENT_TYPE,
431                    HeaderValue::from_str("application/json").unwrap(),
432                )
433                .body(
434                    "{\
435                        \"error\": \"unsupported_token_type\", \"error_description\": \"stuff happened\", \
436                        \"error_uri\": \"https://errors\"\
437                    }"
438                      .to_string()
439                      .into_bytes(),
440                )
441                .unwrap(),
442          ));
443
444        assert!(matches!(
445            revocation_response,
446            Err(RequestTokenError::ServerResponse(
447                BasicRevocationErrorResponse {
448                    error: RevocationErrorResponseType::UnsupportedTokenType,
449                    ..
450                }
451            ))
452        ));
453    }
454
455    #[test]
456    fn test_token_revocation_with_access_token_and_empty_json_response() {
457        let client = new_client()
458            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
459
460        client
461            .revoke_token(AccessToken::new("access_token_123".to_string()).into())
462            .unwrap()
463            .request(&mock_http_client(
464                vec![
465                    (ACCEPT, "application/json"),
466                    (CONTENT_TYPE, "application/x-www-form-urlencoded"),
467                    (AUTHORIZATION, "Basic YWFhOmJiYg=="),
468                ],
469                "token=access_token_123&token_type_hint=access_token",
470                Some("https://revocation/url".parse().unwrap()),
471                Response::builder()
472                    .status(StatusCode::OK)
473                    .header(
474                        CONTENT_TYPE,
475                        HeaderValue::from_str("application/json").unwrap(),
476                    )
477                    .body(b"{}".to_vec())
478                    .unwrap(),
479            ))
480            .unwrap();
481    }
482
483    #[test]
484    fn test_token_revocation_with_access_token_and_empty_response() {
485        let client = new_client()
486            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
487
488        client
489            .revoke_token(AccessToken::new("access_token_123".to_string()).into())
490            .unwrap()
491            .request(&mock_http_client(
492                vec![
493                    (ACCEPT, "application/json"),
494                    (CONTENT_TYPE, "application/x-www-form-urlencoded"),
495                    (AUTHORIZATION, "Basic YWFhOmJiYg=="),
496                ],
497                "token=access_token_123&token_type_hint=access_token",
498                Some("https://revocation/url".parse().unwrap()),
499                Response::builder()
500                    .status(StatusCode::OK)
501                    .body(vec![])
502                    .unwrap(),
503            ))
504            .unwrap();
505    }
506
507    #[test]
508    fn test_token_revocation_with_access_token_and_non_json_response() {
509        let client = new_client()
510            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
511
512        client
513            .revoke_token(AccessToken::new("access_token_123".to_string()).into())
514            .unwrap()
515            .request(&mock_http_client(
516                vec![
517                    (ACCEPT, "application/json"),
518                    (CONTENT_TYPE, "application/x-www-form-urlencoded"),
519                    (AUTHORIZATION, "Basic YWFhOmJiYg=="),
520                ],
521                "token=access_token_123&token_type_hint=access_token",
522                Some("https://revocation/url".parse().unwrap()),
523                Response::builder()
524                    .status(StatusCode::OK)
525                    .header(
526                        CONTENT_TYPE,
527                        HeaderValue::from_str("application/octet-stream").unwrap(),
528                    )
529                    .body(vec![1, 2, 3])
530                    .unwrap(),
531            ))
532            .unwrap();
533    }
534
535    #[test]
536    fn test_token_revocation_with_refresh_token() {
537        let client = new_client()
538            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
539
540        client
541            .revoke_token(RefreshToken::new("refresh_token_123".to_string()).into())
542            .unwrap()
543            .request(&mock_http_client(
544                vec![
545                    (ACCEPT, "application/json"),
546                    (CONTENT_TYPE, "application/x-www-form-urlencoded"),
547                    (AUTHORIZATION, "Basic YWFhOmJiYg=="),
548                ],
549                "token=refresh_token_123&token_type_hint=refresh_token",
550                Some("https://revocation/url".parse().unwrap()),
551                Response::builder()
552                    .status(StatusCode::OK)
553                    .header(
554                        CONTENT_TYPE,
555                        HeaderValue::from_str("application/json").unwrap(),
556                    )
557                    .body(b"{}".to_vec())
558                    .unwrap(),
559            ))
560            .unwrap();
561    }
562
563    #[test]
564    fn test_extension_token_revocation_successful() {
565        let client = ColorfulClient::new(ClientId::new("aaa".to_string()))
566            .set_client_secret(ClientSecret::new("bbb".to_string()))
567            .set_auth_uri(AuthUrl::new("https://example.com/auth".to_string()).unwrap())
568            .set_token_uri(TokenUrl::new("https://example.com/token".to_string()).unwrap())
569            .set_revocation_url(RevocationUrl::new("https://revocation/url".to_string()).unwrap());
570
571        client
572            .revoke_token(ColorfulRevocableToken::Red(
573                "colorful_token_123".to_string(),
574            ))
575            .unwrap()
576            .request(&mock_http_client(
577                vec![
578                    (ACCEPT, "application/json"),
579                    (CONTENT_TYPE, "application/x-www-form-urlencoded"),
580                    (AUTHORIZATION, "Basic YWFhOmJiYg=="),
581                ],
582                "token=colorful_token_123&token_type_hint=red_token",
583                Some("https://revocation/url".parse().unwrap()),
584                Response::builder()
585                    .status(StatusCode::OK)
586                    .header(
587                        CONTENT_TYPE,
588                        HeaderValue::from_str("application/json").unwrap(),
589                    )
590                    .body(b"{}".to_vec())
591                    .unwrap(),
592            ))
593            .unwrap();
594    }
595}