revoke.rs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. //! Token revocation (RFC 7009 §2.1).
  2. //!
  3. //! [`revoke`] POSTs a form-encoded (`application/x-www-form-urlencoded`) body
  4. //! with `token` and an optional `token_type_hint` to the
  5. //! discovery-provided `revocation_endpoint` with HTTP Basic client auth. An
  6. //! HTTP 200 is success (RFC 7009 §2.2.1 treats unknown tokens as success too,
  7. //! so callers must not infer token validity from revocation). No retry.
  8. use secrecy::{ExposeSecret, SecretString};
  9. use crate::{
  10. client_auth::basic_auth_header,
  11. config::ClientCredentials,
  12. discovery::ServerMetadata,
  13. error::{OAuthError, Result},
  14. };
  15. /// Revoke `token` at the discovery `revocation_endpoint`.
  16. ///
  17. /// Form-encoded POST of `token` plus the optional `token_type_hint`, Basic
  18. /// client auth, exactly one request, no retry. HTTP 200 is success.
  19. pub async fn revoke(
  20. client: &reqwest::Client,
  21. metadata: &ServerMetadata,
  22. credentials: &ClientCredentials,
  23. token: &SecretString,
  24. token_type_hint: Option<&str>,
  25. ) -> Result<()> {
  26. let Some(endpoint) = metadata.revocation_endpoint.as_deref() else {
  27. return Err(OAuthError::Unsupported {
  28. capability: "revocation".to_owned(),
  29. });
  30. };
  31. let mut fields = vec![("token".to_owned(), token.expose_secret().to_owned())];
  32. if let Some(hint) = token_type_hint {
  33. fields.push(("token_type_hint".to_owned(), hint.to_owned()));
  34. }
  35. let response = client
  36. .post(endpoint)
  37. .header(
  38. reqwest::header::AUTHORIZATION,
  39. basic_auth_header(credentials),
  40. )
  41. .form(&fields)
  42. .send()
  43. .await
  44. .map_err(|err| OAuthError::Http {
  45. reason: format!("revocation request failed: {err}"),
  46. })?;
  47. if !response.status().is_success() {
  48. let status = response.status();
  49. return Err(OAuthError::Http {
  50. reason: format!("revocation endpoint returned status {status}"),
  51. });
  52. }
  53. Ok(())
  54. }
  55. #[cfg(test)]
  56. mod tests {
  57. use std::collections::HashMap;
  58. use wiremock::{
  59. Mock, MockServer, ResponseTemplate,
  60. matchers::{method, path},
  61. };
  62. use super::*;
  63. use crate::discovery::build_client;
  64. fn credentials() -> ClientCredentials {
  65. ClientCredentials {
  66. client_id: "demo-client".to_owned(),
  67. client_secret: SecretString::from("demo-secret".to_owned()),
  68. }
  69. }
  70. fn metadata_for(server: &MockServer) -> ServerMetadata {
  71. ServerMetadata {
  72. issuer: server.uri(),
  73. revocation_endpoint: Some(format!("{}/revoke", server.uri())),
  74. ..ServerMetadata::default()
  75. }
  76. }
  77. #[tokio::test]
  78. async fn revoke_posts_token_form_and_succeeds_on_200() {
  79. let server = MockServer::start().await;
  80. Mock::given(method("POST"))
  81. .and(path("/revoke"))
  82. .respond_with(ResponseTemplate::new(200))
  83. .mount(&server)
  84. .await;
  85. let client = build_client().unwrap();
  86. revoke(
  87. &client,
  88. &metadata_for(&server),
  89. &credentials(),
  90. &SecretString::from("opaque-refresh-1".to_owned()),
  91. Some("refresh_token"),
  92. )
  93. .await
  94. .unwrap();
  95. let received = server.received_requests().await.unwrap();
  96. assert_eq!(received.len(), 1);
  97. let sent = &received[0];
  98. assert!(
  99. sent
  100. .headers
  101. .get("authorization")
  102. .is_some_and(|value| value.to_str().unwrap().starts_with("Basic ")),
  103. "revocation requires Basic client auth"
  104. );
  105. let body: HashMap<String, String> =
  106. serde_urlencoded::from_str(&String::from_utf8_lossy(&sent.body)).unwrap();
  107. assert_eq!(
  108. body.get("token").map(String::as_str),
  109. Some("opaque-refresh-1")
  110. );
  111. assert_eq!(
  112. body.get("token_type_hint").map(String::as_str),
  113. Some("refresh_token")
  114. );
  115. }
  116. #[tokio::test]
  117. async fn revoke_without_discovered_endpoint_is_unsupported() {
  118. let client = build_client().unwrap();
  119. let result = revoke(
  120. &client,
  121. &ServerMetadata::default(),
  122. &credentials(),
  123. &SecretString::from("token".to_owned()),
  124. None,
  125. )
  126. .await;
  127. assert!(
  128. matches!(
  129. result,
  130. Err(OAuthError::Unsupported { ref capability })
  131. if capability == "revocation"
  132. ),
  133. "expected Unsupported(revocation), got {result:?}"
  134. );
  135. }
  136. #[tokio::test]
  137. async fn revoke_failure_status_is_a_typed_http_error() {
  138. let server = MockServer::start().await;
  139. Mock::given(method("POST"))
  140. .and(path("/revoke"))
  141. .respond_with(ResponseTemplate::new(500))
  142. .mount(&server)
  143. .await;
  144. let client = build_client().unwrap();
  145. let result = revoke(
  146. &client,
  147. &metadata_for(&server),
  148. &credentials(),
  149. &SecretString::from("opaque-refresh-1".to_owned()),
  150. None,
  151. )
  152. .await;
  153. assert!(
  154. matches!(result, Err(OAuthError::Http { .. })),
  155. "a failing revocation must surface Http, got {result:?}"
  156. );
  157. }
  158. }