//! 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 = 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:?}" ); } }