#![cfg(feature = "auth_code")]
use crate::auth::token::{AccessToken, TokenResponse};
use crate::error::{AuthenticationError, ForceError, HttpError, Result};
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use secrecy::{ExposeSecret, SecretString};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use tokio::sync::RwLock;
const CODE_CHALLENGE_METHOD: &str = "S256";
const VERIFIER_ENTROPY_BYTES: usize = 32;
const MIN_VERIFIER_LEN: usize = 43;
const MAX_VERIFIER_LEN: usize = 128;
#[derive(Clone)]
pub struct PkceChallenge {
verifier: SecretString,
challenge: String,
}
impl std::fmt::Debug for PkceChallenge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PkceChallenge")
.field("verifier", &"[REDACTED]")
.field("challenge", &self.challenge)
.field("method", &CODE_CHALLENGE_METHOD)
.finish()
}
}
impl PkceChallenge {
#[must_use]
pub fn generate() -> Self {
let mut bytes = [0u8; VERIFIER_ENTROPY_BYTES];
getrandom::getrandom(&mut bytes)
.unwrap_or_else(|e| panic!("secure RNG unavailable for PKCE verifier: {e}"));
let verifier = URL_SAFE_NO_PAD.encode(bytes);
let challenge = derive_challenge(&verifier);
Self {
verifier: SecretString::new(verifier.into()),
challenge,
}
}
pub fn from_verifier(verifier: impl Into<String>) -> Result<Self> {
let verifier = verifier.into();
validate_verifier(&verifier)?;
let challenge = derive_challenge(&verifier);
Ok(Self {
verifier: SecretString::new(verifier.into()),
challenge,
})
}
#[must_use]
pub fn verifier(&self) -> &str {
self.verifier.expose_secret()
}
#[must_use]
pub fn challenge(&self) -> &str {
&self.challenge
}
#[must_use]
pub const fn method(&self) -> &'static str {
CODE_CHALLENGE_METHOD
}
}
fn derive_challenge(verifier: &str) -> String {
let digest = Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn validate_verifier(verifier: &str) -> Result<()> {
let len = verifier.len();
if !(MIN_VERIFIER_LEN..=MAX_VERIFIER_LEN).contains(&len) {
return Err(ForceError::Authentication(
AuthenticationError::InvalidCredentials(format!(
"code_verifier must be {MIN_VERIFIER_LEN}-{MAX_VERIFIER_LEN} characters, got {len}"
)),
));
}
if let Some(bad) = verifier
.bytes()
.find(|&b| !is_unreserved(b))
.map(|b| b as char)
{
return Err(ForceError::Authentication(
AuthenticationError::InvalidCredentials(format!(
"code_verifier contains illegal character {bad:?}; only [A-Za-z0-9-._~] are allowed"
)),
));
}
Ok(())
}
const fn is_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
}
#[derive(Debug, Clone)]
pub struct AuthorizeUrlBuilder {
login_url: String,
client_id: String,
redirect_uri: String,
scopes: Vec<String>,
state: Option<String>,
code_challenge: String,
}
impl AuthorizeUrlBuilder {
pub fn new(
login_url: impl Into<String>,
client_id: impl Into<String>,
redirect_uri: impl Into<String>,
challenge: &PkceChallenge,
) -> Self {
Self {
login_url: login_url.into(),
client_id: client_id.into(),
redirect_uri: redirect_uri.into(),
scopes: Vec::new(),
state: None,
code_challenge: challenge.challenge().to_string(),
}
}
pub fn new_production(
client_id: impl Into<String>,
redirect_uri: impl Into<String>,
challenge: &PkceChallenge,
) -> Self {
Self::new(
crate::auth::PRODUCTION_LOGIN_URL,
client_id,
redirect_uri,
challenge,
)
}
pub fn new_sandbox(
client_id: impl Into<String>,
redirect_uri: impl Into<String>,
challenge: &PkceChallenge,
) -> Self {
Self::new(
crate::auth::SANDBOX_LOGIN_URL,
client_id,
redirect_uri,
challenge,
)
}
#[must_use]
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
#[must_use]
pub fn scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes.extend(scopes.into_iter().map(Into::into));
self
}
#[must_use]
pub fn state(mut self, state: impl Into<String>) -> Self {
self.state = Some(state.into());
self
}
#[must_use]
pub fn build(&self) -> String {
let base = format!(
"{}/services/oauth2/authorize",
self.login_url.trim_end_matches('/')
);
let Ok(mut url) = url::Url::parse(&base) else {
return base;
};
{
let mut qp = url.query_pairs_mut();
qp.append_pair("response_type", "code");
qp.append_pair("client_id", &self.client_id);
qp.append_pair("redirect_uri", &self.redirect_uri);
if !self.scopes.is_empty() {
qp.append_pair("scope", &self.scopes.join(" "));
}
if let Some(state) = &self.state {
qp.append_pair("state", state);
}
qp.append_pair("code_challenge", &self.code_challenge);
qp.append_pair("code_challenge_method", CODE_CHALLENGE_METHOD);
}
url.into()
}
}
#[derive(Clone)]
pub struct AuthorizationCode {
client_id: String,
client_secret: Option<SecretString>,
redirect_uri: String,
token_url: String,
code: SecretString,
code_verifier: SecretString,
client: reqwest::Client,
refresh_token: Arc<RwLock<Option<SecretString>>>,
}
impl std::fmt::Debug for AuthorizationCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthorizationCode")
.field("client_id", &self.client_id)
.field(
"client_secret",
&self.client_secret.as_ref().map(|_| "[REDACTED]"),
)
.field("redirect_uri", &self.redirect_uri)
.field("token_url", &self.token_url)
.field("code", &"[REDACTED]")
.field("code_verifier", &"[REDACTED]")
.finish()
}
}
impl AuthorizationCode {
pub fn new(
client_id: impl Into<String>,
client_secret: Option<String>,
redirect_uri: impl Into<String>,
code: impl Into<String>,
code_verifier: impl Into<String>,
token_url: impl Into<String>,
) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.map(|s| SecretString::new(s.into())),
redirect_uri: redirect_uri.into(),
token_url: token_url.into(),
code: SecretString::new(code.into().into()),
code_verifier: SecretString::new(code_verifier.into().into()),
client: crate::auth::default_auth_http_client(),
refresh_token: Arc::new(RwLock::new(None)),
}
}
pub fn new_production(
client_id: impl Into<String>,
client_secret: Option<String>,
redirect_uri: impl Into<String>,
code: impl Into<String>,
code_verifier: impl Into<String>,
) -> Self {
Self::new(
client_id,
client_secret,
redirect_uri,
code,
code_verifier,
crate::auth::PRODUCTION_TOKEN_URL,
)
}
pub fn new_sandbox(
client_id: impl Into<String>,
client_secret: Option<String>,
redirect_uri: impl Into<String>,
code: impl Into<String>,
code_verifier: impl Into<String>,
) -> Self {
Self::new(
client_id,
client_secret,
redirect_uri,
code,
code_verifier,
crate::auth::SANDBOX_TOKEN_URL,
)
}
#[must_use]
pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = client;
self
}
pub async fn refresh_token(&self) -> Option<String> {
self.refresh_token
.read()
.await
.as_ref()
.map(|s| s.expose_secret().to_string())
}
pub async fn set_refresh_token(&self, refresh_token: impl Into<String>) {
let mut stored = self.refresh_token.write().await;
*stored = Some(SecretString::new(refresh_token.into().into()));
}
fn revoke_url(&self) -> String {
match self.token_url.rfind("/token") {
Some(idx) => format!("{}/revoke", &self.token_url[..idx]),
None => format!(
"{}/services/oauth2/revoke",
self.token_url.trim_end_matches('/')
),
}
}
pub async fn revoke(&self, token: &str) -> Result<()> {
let params = [("token", token)];
let response = self
.client
.post(self.revoke_url())
.form(¶ms)
.send()
.await
.map_err(|e| ForceError::Http(HttpError::RequestFailed(e)))?;
if response.status().is_success() {
Ok(())
} else {
Err(crate::auth::handle_oauth_error(response, Some("token revocation failed")).await)
}
}
pub async fn revoke_stored_refresh_token(&self) -> Result<()> {
let stored = self.refresh_token.read().await.clone();
if let Some(rt) = stored {
self.revoke(rt.expose_secret()).await?;
let mut guard = self.refresh_token.write().await;
*guard = None;
}
Ok(())
}
async fn send_token_request(&self, params: &[(&str, &str)]) -> Result<TokenResponse> {
let response = self
.client
.post(&self.token_url)
.form(params)
.send()
.await
.map_err(|e| ForceError::Http(HttpError::RequestFailed(e)))?;
if !response.status().is_success() {
return Err(crate::auth::handle_oauth_error(response, None).await);
}
let bytes = crate::http::error::read_capped_body_bytes(response, 1024 * 1024).await?;
serde_json::from_slice::<TokenResponse>(&bytes)
.map_err(crate::error::SerializationError::from)
.map_err(Into::into)
}
async fn store_refresh_token(&self, response: &TokenResponse) {
if let Some(ref rt) = response.refresh_token {
let mut stored = self.refresh_token.write().await;
*stored = Some(rt.clone());
}
}
}
#[async_trait]
impl crate::auth::authenticator::Authenticator for AuthorizationCode {
async fn authenticate(&self) -> Result<AccessToken> {
let mut params: Vec<(&str, &str)> = vec![
("grant_type", "authorization_code"),
("client_id", self.client_id.as_str()),
("redirect_uri", self.redirect_uri.as_str()),
("code", self.code.expose_secret()),
("code_verifier", self.code_verifier.expose_secret()),
];
if let Some(secret) = &self.client_secret {
params.push(("client_secret", secret.expose_secret()));
}
let token_response = self.send_token_request(¶ms).await?;
self.store_refresh_token(&token_response).await;
Ok(AccessToken::from_response(token_response))
}
async fn refresh(&self) -> Result<AccessToken> {
let stored_rt = self.refresh_token.read().await.clone();
if let Some(rt) = stored_rt {
let mut params: Vec<(&str, &str)> = vec![
("grant_type", "refresh_token"),
("client_id", self.client_id.as_str()),
("refresh_token", rt.expose_secret()),
];
if let Some(secret) = &self.client_secret {
params.push(("client_secret", secret.expose_secret()));
}
if let Ok(token_response) = self.send_token_request(¶ms).await {
self.store_refresh_token(&token_response).await;
return Ok(AccessToken::from_response(token_response));
}
let mut stored = self.refresh_token.write().await;
if stored.as_ref().map(|s| s.expose_secret()) == Some(rt.expose_secret()) {
*stored = None;
}
}
self.authenticate().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::Authenticator;
use crate::error::AuthenticationError;
use crate::test_utils::must::Must;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn sample_token_response() -> serde_json::Value {
serde_json::json!({
"access_token": "00Dxx0000001gPL!auth_code_token",
"instance_url": "https://test.my.salesforce.com",
"token_type": "Bearer",
"issued_at": "1704067200000",
"signature": "testSignature==",
"refresh_token": "fake_refresh_token_for_testing"
})
}
#[test]
fn test_generate_verifier_length_and_charset() {
let pkce = PkceChallenge::generate();
let verifier = pkce.verifier();
assert!(
(MIN_VERIFIER_LEN..=MAX_VERIFIER_LEN).contains(&verifier.len()),
"verifier length {} out of range",
verifier.len()
);
assert!(
verifier.bytes().all(is_unreserved),
"verifier contains characters outside the unreserved set"
);
}
#[test]
fn test_generate_produces_unique_verifiers() {
let a = PkceChallenge::generate();
let b = PkceChallenge::generate();
assert_ne!(a.verifier(), b.verifier());
assert_ne!(a.challenge(), b.challenge());
}
#[test]
fn test_challenge_derivation_known_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
let pkce = PkceChallenge::from_verifier(verifier).must();
assert_eq!(pkce.challenge(), expected_challenge);
assert_eq!(pkce.verifier(), verifier);
assert_eq!(pkce.method(), "S256");
}
#[test]
fn test_from_verifier_rejects_short() {
let short = "tooshort";
let result = PkceChallenge::from_verifier(short);
assert!(matches!(
result,
Err(ForceError::Authentication(
AuthenticationError::InvalidCredentials(_)
))
));
}
#[test]
fn test_from_verifier_rejects_long() {
let long = "a".repeat(129);
let result = PkceChallenge::from_verifier(long);
assert!(matches!(
result,
Err(ForceError::Authentication(
AuthenticationError::InvalidCredentials(_)
))
));
}
#[test]
fn test_from_verifier_rejects_illegal_char() {
let bad = format!("{}{}", "a".repeat(42), " ");
let result = PkceChallenge::from_verifier(bad);
assert!(matches!(
result,
Err(ForceError::Authentication(
AuthenticationError::InvalidCredentials(_)
))
));
}
#[test]
fn test_from_verifier_accepts_boundary_lengths() {
assert!(PkceChallenge::from_verifier("a".repeat(MIN_VERIFIER_LEN)).is_ok());
assert!(PkceChallenge::from_verifier("a".repeat(MAX_VERIFIER_LEN)).is_ok());
}
#[test]
fn test_pkce_debug_redacts_verifier() {
let pkce = PkceChallenge::from_verifier("a".repeat(MIN_VERIFIER_LEN)).must();
let debug = format!("{pkce:?}");
assert!(debug.contains("[REDACTED]"));
assert!(debug.contains(pkce.challenge()));
assert!(!debug.contains(&"a".repeat(MIN_VERIFIER_LEN)));
}
#[test]
fn test_authorize_url_contains_required_params() {
let pkce =
PkceChallenge::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").must();
let url = AuthorizeUrlBuilder::new_production(
"myClientId",
"https://app.example.com/callback",
&pkce,
)
.scope("api")
.scope("refresh_token")
.state("xyz-state")
.build();
assert!(url.starts_with("https://login.salesforce.com/services/oauth2/authorize?"));
assert!(url.contains("response_type=code"));
assert!(url.contains("client_id=myClientId"));
assert!(url.contains("redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback"));
assert!(url.contains("scope=api+refresh_token"));
assert!(url.contains("state=xyz-state"));
assert!(url.contains(&format!("code_challenge={}", pkce.challenge())));
assert!(url.contains("code_challenge_method=S256"));
}
#[test]
fn test_authorize_url_sandbox_host() {
let pkce = PkceChallenge::generate();
let url = AuthorizeUrlBuilder::new_sandbox("cid", "https://cb.example.com", &pkce).build();
assert!(url.starts_with("https://test.salesforce.com/services/oauth2/authorize?"));
}
#[test]
fn test_authorize_url_omits_scope_and_state_when_absent() {
let pkce = PkceChallenge::generate();
let url = AuthorizeUrlBuilder::new("https://my.example.com/", "cid", "cb", &pkce).build();
assert!(url.starts_with("https://my.example.com/services/oauth2/authorize?"));
assert!(!url.contains("scope="));
assert!(!url.contains("state="));
}
#[test]
fn test_authorize_url_scopes_iter() {
let pkce = PkceChallenge::generate();
let url = AuthorizeUrlBuilder::new_production("cid", "cb", &pkce)
.scopes(["api", "web", "refresh_token"])
.build();
assert!(url.contains("scope=api+web+refresh_token"));
}
#[test]
fn test_new_production_token_url() {
let auth = AuthorizationCode::new_production(
"cid",
None,
"https://cb.example.com",
"the_code",
"the_verifier",
);
assert_eq!(
auth.token_url,
"https://login.salesforce.com/services/oauth2/token"
);
}
#[test]
fn test_new_sandbox_token_url() {
let auth = AuthorizationCode::new_sandbox(
"cid",
None,
"https://cb.example.com",
"the_code",
"the_verifier",
);
assert_eq!(
auth.token_url,
"https://test.salesforce.com/services/oauth2/token"
);
}
#[test]
fn test_debug_redacts_secrets() {
let auth = AuthorizationCode::new(
"cid",
Some("super_secret".to_string()),
"https://cb.example.com",
"secret_code",
"secret_verifier",
"https://login.salesforce.com/services/oauth2/token",
);
let debug = format!("{auth:?}");
assert!(debug.contains("cid"));
assert!(!debug.contains("super_secret"));
assert!(!debug.contains("secret_code"));
assert!(!debug.contains("secret_verifier"));
assert!(debug.contains("[REDACTED]"));
}
#[test]
fn test_revoke_url_derivation() {
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"code",
"verifier",
"https://login.salesforce.com/services/oauth2/token",
);
assert_eq!(
auth.revoke_url(),
"https://login.salesforce.com/services/oauth2/revoke"
);
}
#[tokio::test]
async fn test_authenticate_success_public_client() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.and(body_string_contains("grant_type=authorization_code"))
.and(body_string_contains("code=the_code"))
.and(body_string_contains("code_verifier=the_verifier"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"https://cb.example.com",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let token = auth.authenticate().await.must();
assert_eq!(token.as_str(), "00Dxx0000001gPL!auth_code_token");
assert_eq!(token.instance_url(), "https://test.my.salesforce.com");
}
#[tokio::test]
async fn test_authenticate_confidential_client_sends_secret() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.and(body_string_contains("client_secret=my_secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
Some("my_secret".to_string()),
"https://cb.example.com",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let token = auth.authenticate().await.must();
assert_eq!(token.as_str(), "00Dxx0000001gPL!auth_code_token");
}
#[tokio::test]
async fn test_authenticate_stores_refresh_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let _token = auth.authenticate().await.must();
assert_eq!(
auth.refresh_token().await,
Some("fake_refresh_token_for_testing".to_string())
);
}
#[tokio::test]
async fn test_authenticate_invalid_grant() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
"error_description": "expired authorization code"
})))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"used_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let result = auth.authenticate().await;
if let Err(ForceError::Authentication(AuthenticationError::TokenRequestFailed(msg))) =
result
{
assert!(msg.contains("invalid_grant"));
assert!(msg.contains("expired authorization code"));
} else {
panic!("Expected TokenRequestFailed error, got {result:?}");
}
}
#[tokio::test]
async fn test_refresh_uses_refresh_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_string_contains("grant_type=authorization_code"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains(
"refresh_token=fake_refresh_token_for_testing",
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "refreshed_access_token",
"instance_url": "https://test.my.salesforce.com",
"token_type": "Bearer",
"issued_at": "1704070800000",
"signature": "newSig==",
"refresh_token": "rotated_refresh_token"
})))
.expect(1)
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let _t1 = auth.authenticate().await.must();
let t2 = auth.refresh().await.must();
assert_eq!(t2.as_str(), "refreshed_access_token");
assert_eq!(
auth.refresh_token().await,
Some("rotated_refresh_token".to_string())
);
}
#[tokio::test]
async fn test_refresh_falls_back_to_code_exchange_when_revoked() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_string_contains("grant_type=authorization_code"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(body_string_contains("grant_type=refresh_token"))
.respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": "invalid_grant",
"error_description": "token revoked"
})))
.expect(1)
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
let _t1 = auth.authenticate().await.must();
let t2 = auth.refresh().await.must();
assert_eq!(t2.as_str(), "00Dxx0000001gPL!auth_code_token");
}
#[tokio::test]
async fn test_set_refresh_token_then_refresh() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=seeded_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "seeded_flow_token",
"instance_url": "https://test.my.salesforce.com",
"token_type": "Bearer",
"issued_at": "1704070800000",
"signature": "sig=="
})))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"unused_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
);
auth.set_refresh_token("seeded_token").await;
let token = auth.refresh().await.must();
assert_eq!(token.as_str(), "seeded_flow_token");
}
#[tokio::test]
async fn test_revoke_success() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/revoke"))
.and(body_string_contains("token=some_token"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"code",
"verifier",
format!("{}/services/oauth2/token", server.uri()),
);
assert!(auth.revoke("some_token").await.is_ok());
}
#[tokio::test]
async fn test_revoke_stored_refresh_token_clears_it() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/revoke"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"code",
"verifier",
format!("{}/services/oauth2/token", server.uri()),
);
auth.set_refresh_token("to_revoke").await;
assert!(auth.revoke_stored_refresh_token().await.is_ok());
assert_eq!(auth.refresh_token().await, None);
}
#[tokio::test]
async fn test_revoke_stored_refresh_token_noop_when_absent() {
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"code",
"verifier",
"https://login.salesforce.com/services/oauth2/token",
);
assert!(auth.revoke_stored_refresh_token().await.is_ok());
}
#[tokio::test]
async fn test_with_client_custom_http_client() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(sample_token_response()))
.mount(&server)
.await;
let custom_client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.must();
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"the_code",
"the_verifier",
format!("{}/services/oauth2/token", server.uri()),
)
.with_client(custom_client);
let token = auth.authenticate().await.must();
assert_eq!(token.as_str(), "00Dxx0000001gPL!auth_code_token");
}
#[tokio::test]
async fn test_authenticate_network_error() {
let auth = AuthorizationCode::new(
"cid",
None,
"cb",
"code",
"verifier",
"http://invalid.invalid.localhost:99999/oauth2/token",
);
let result = auth.authenticate().await;
assert!(matches!(result, Err(ForceError::Http(_))));
}
}