| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- //! Token revocation (RFC 7009 §2.1).
- //!
- //! [`revoke`] POSTs a form-encoded (`application/x-www-form-urlencoded`) body
- //! with `token` and an optional `token_type_hint` to the
- //! discovery-provided `revocation_endpoint` with HTTP Basic client auth. An
- //! HTTP 200 is success (RFC 7009 §2.2.1 treats unknown tokens as success too,
- //! so callers must not infer token validity from revocation). No retry.
- use secrecy::{ExposeSecret, SecretString};
- use crate::{
- client_auth::basic_auth_header,
- config::ClientCredentials,
- discovery::ServerMetadata,
- error::{OAuthError, Result},
- };
- /// Revoke `token` at the discovery `revocation_endpoint`.
- ///
- /// Form-encoded POST of `token` plus the optional `token_type_hint`, Basic
- /// client auth, exactly one request, no retry. HTTP 200 is success.
- pub async fn revoke(
- client: &reqwest::Client,
- metadata: &ServerMetadata,
- credentials: &ClientCredentials,
- token: &SecretString,
- token_type_hint: Option<&str>,
- ) -> Result<()> {
- let Some(endpoint) = metadata.revocation_endpoint.as_deref() else {
- return Err(OAuthError::Unsupported {
- capability: "revocation".to_owned(),
- });
- };
- let mut fields = vec![("token".to_owned(), token.expose_secret().to_owned())];
- if let Some(hint) = token_type_hint {
- fields.push(("token_type_hint".to_owned(), hint.to_owned()));
- }
- let response = client
- .post(endpoint)
- .header(
- reqwest::header::AUTHORIZATION,
- basic_auth_header(credentials),
- )
- .form(&fields)
- .send()
- .await
- .map_err(|err| OAuthError::Http {
- reason: format!("revocation request failed: {err}"),
- })?;
- if !response.status().is_success() {
- let status = response.status();
- return Err(OAuthError::Http {
- reason: format!("revocation endpoint returned status {status}"),
- });
- }
- Ok(())
- }
- #[cfg(test)]
- mod tests {
- use std::collections::HashMap;
- use wiremock::{
- Mock, MockServer, ResponseTemplate,
- matchers::{method, path},
- };
- use super::*;
- use crate::discovery::build_client;
- fn credentials() -> ClientCredentials {
- ClientCredentials {
- client_id: "demo-client".to_owned(),
- client_secret: SecretString::from("demo-secret".to_owned()),
- }
- }
- fn metadata_for(server: &MockServer) -> ServerMetadata {
- ServerMetadata {
- issuer: server.uri(),
- revocation_endpoint: Some(format!("{}/revoke", server.uri())),
- ..ServerMetadata::default()
- }
- }
- #[tokio::test]
- async fn revoke_posts_token_form_and_succeeds_on_200() {
- let server = MockServer::start().await;
- Mock::given(method("POST"))
- .and(path("/revoke"))
- .respond_with(ResponseTemplate::new(200))
- .mount(&server)
- .await;
- let client = build_client().unwrap();
- revoke(
- &client,
- &metadata_for(&server),
- &credentials(),
- &SecretString::from("opaque-refresh-1".to_owned()),
- Some("refresh_token"),
- )
- .await
- .unwrap();
- let received = server.received_requests().await.unwrap();
- assert_eq!(received.len(), 1);
- let sent = &received[0];
- assert!(
- sent
- .headers
- .get("authorization")
- .is_some_and(|value| value.to_str().unwrap().starts_with("Basic ")),
- "revocation requires Basic client auth"
- );
- let body: HashMap<String, String> =
- serde_urlencoded::from_str(&String::from_utf8_lossy(&sent.body)).unwrap();
- assert_eq!(
- body.get("token").map(String::as_str),
- Some("opaque-refresh-1")
- );
- assert_eq!(
- body.get("token_type_hint").map(String::as_str),
- Some("refresh_token")
- );
- }
- #[tokio::test]
- async fn revoke_without_discovered_endpoint_is_unsupported() {
- let client = build_client().unwrap();
- let result = revoke(
- &client,
- &ServerMetadata::default(),
- &credentials(),
- &SecretString::from("token".to_owned()),
- None,
- )
- .await;
- assert!(
- matches!(
- result,
- Err(OAuthError::Unsupported { ref capability })
- if capability == "revocation"
- ),
- "expected Unsupported(revocation), got {result:?}"
- );
- }
- #[tokio::test]
- async fn revoke_failure_status_is_a_typed_http_error() {
- let server = MockServer::start().await;
- Mock::given(method("POST"))
- .and(path("/revoke"))
- .respond_with(ResponseTemplate::new(500))
- .mount(&server)
- .await;
- let client = build_client().unwrap();
- let result = revoke(
- &client,
- &metadata_for(&server),
- &credentials(),
- &SecretString::from("opaque-refresh-1".to_owned()),
- None,
- )
- .await;
- assert!(
- matches!(result, Err(OAuthError::Http { .. })),
- "a failing revocation must surface Http, got {result:?}"
- );
- }
- }
|