use chrono::{DateTime, Utc};
use oauth2::basic::BasicClient;
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, HttpClientError, HttpRequest,
HttpResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, TokenResponse,
TokenUrl,
};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthTokens {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: Option<DateTime<Utc>>,
}
fn tokens_from_response<TR: TokenResponse>(response: &TR) -> OAuthTokens {
OAuthTokens {
access_token: response.access_token().secret().clone(),
refresh_token: response.refresh_token().map(|rt| rt.secret().clone()),
expires_at: response
.expires_in()
.and_then(|d| chrono::Duration::from_std(d).ok())
.map(|d| Utc::now() + d),
}
}
pub struct OAuthConfig {
client_id: ClientId,
client_secret: ClientSecret,
auth_url: AuthUrl,
token_url: TokenUrl,
redirect_uri: RedirectUrl,
http_client: reqwest::Client,
}
impl OAuthConfig {
pub fn new(
client_id: impl Into<String>,
client_secret: impl Into<String>,
auth_url: impl Into<String>,
token_url: impl Into<String>,
redirect_uri: impl Into<String>,
) -> Result<Self, Error> {
Ok(Self {
client_id: ClientId::new(client_id.into()),
client_secret: ClientSecret::new(client_secret.into()),
auth_url: AuthUrl::new(auth_url.into())?,
token_url: TokenUrl::new(token_url.into())?,
redirect_uri: RedirectUrl::new(redirect_uri.into())?,
http_client: reqwest::Client::new(),
})
}
async fn http_response(
&self,
request: HttpRequest,
) -> Result<HttpResponse, HttpClientError<reqwest::Error>> {
let response = self
.http_client
.execute(request.try_into().map_err(Box::new)?)
.await
.map_err(Box::new)?;
let mut builder = oauth2::http::Response::builder().status(response.status());
for (name, value) in response.headers().iter() {
builder = builder.header(name, value);
}
builder
.body(response.bytes().await.map_err(Box::new)?.to_vec())
.map_err(HttpClientError::Http)
}
pub fn authorization_url(&self) -> (Url, PkceCodeVerifier, CsrfToken) {
let client = BasicClient::new(self.client_id.clone())
.set_client_secret(self.client_secret.clone())
.set_auth_uri(self.auth_url.clone())
.set_token_uri(self.token_url.clone())
.set_redirect_uri(self.redirect_uri.clone());
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token) = client
.authorize_url(CsrfToken::new_random)
.set_pkce_challenge(pkce_challenge)
.url();
(auth_url, pkce_verifier, csrf_token)
}
pub async fn exchange_code(
&self,
code: impl Into<String>,
verifier: PkceCodeVerifier,
) -> Result<OAuthTokens, Error> {
let client = BasicClient::new(self.client_id.clone())
.set_client_secret(self.client_secret.clone())
.set_auth_uri(self.auth_url.clone())
.set_token_uri(self.token_url.clone())
.set_redirect_uri(self.redirect_uri.clone());
let response = client
.exchange_code(AuthorizationCode::new(code.into()))
.set_pkce_verifier(verifier)
.request_async(&|req| self.http_response(req))
.await
.map_err(|e| Error::OAuth(format!("{e:?}")))?;
Ok(tokens_from_response(&response))
}
pub async fn refresh_token(
&self,
refresh_token: impl Into<String>,
) -> Result<OAuthTokens, Error> {
let client = BasicClient::new(self.client_id.clone())
.set_client_secret(self.client_secret.clone())
.set_auth_uri(self.auth_url.clone())
.set_token_uri(self.token_url.clone())
.set_redirect_uri(self.redirect_uri.clone());
let refresh_token = RefreshToken::new(refresh_token.into());
let response = client
.exchange_refresh_token(&refresh_token)
.request_async(&|req| self.http_response(req))
.await
.map_err(|e| Error::OAuth(format!("{e:?}")))?;
Ok(tokens_from_response(&response))
}
pub fn verify_and_extract_code(
redirect_url: &str,
csrf_token: &CsrfToken,
) -> Result<String, Error> {
let url = Url::parse(redirect_url)?;
let state = url
.query_pairs()
.find(|(key, _)| key == "state")
.map(|(_, value)| value.into_owned())
.ok_or_else(|| Error::OAuth("redirect URL is missing the `state` param".to_string()))?;
if &state != csrf_token.secret() {
return Err(Error::OAuth(
"`state` param did not match the expected CSRF token".to_string(),
));
}
url.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, value)| value.into_owned())
.ok_or_else(|| Error::OAuth("redirect URL is missing the `code` param".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_config(server: &MockServer) -> OAuthConfig {
OAuthConfig::new(
"test-client-id",
"test-client-secret",
"http://example.com/authorize",
format!("{}/token", server.uri()),
"http://localhost:8080/callback",
)
.unwrap()
}
#[test]
fn verify_and_extract_code_succeeds_when_state_matches() {
let csrf_token = CsrfToken::new("expected-state".to_string());
let redirect_url = "https://your-app.example.com/callback?code=abc123&state=expected-state";
let code = OAuthConfig::verify_and_extract_code(redirect_url, &csrf_token).unwrap();
assert_eq!(code, "abc123");
}
#[test]
fn verify_and_extract_code_rejects_mismatched_state() {
let csrf_token = CsrfToken::new("expected-state".to_string());
let redirect_url = "https://your-app.example.com/callback?code=abc123&state=wrong-state";
let result = OAuthConfig::verify_and_extract_code(redirect_url, &csrf_token);
assert!(matches!(result, Err(Error::OAuth(_))));
}
#[test]
fn verify_and_extract_code_rejects_missing_state() {
let csrf_token = CsrfToken::new("expected-state".to_string());
let redirect_url = "https://your-app.example.com/callback?code=abc123";
let result = OAuthConfig::verify_and_extract_code(redirect_url, &csrf_token);
assert!(matches!(result, Err(Error::OAuth(_))));
}
#[test]
fn verify_and_extract_code_rejects_missing_code() {
let csrf_token = CsrfToken::new("expected-state".to_string());
let redirect_url = "https://your-app.example.com/callback?state=expected-state";
let result = OAuthConfig::verify_and_extract_code(redirect_url, &csrf_token);
assert!(matches!(result, Err(Error::OAuth(_))));
}
fn verifier() -> PkceCodeVerifier {
let (_challenge, verifier) = PkceCodeChallenge::new_random_sha256();
verifier
}
#[tokio::test]
async fn exchange_code_returns_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "test-access-token",
"token_type": "bearer",
"refresh_token": "test-refresh-token",
"expires_in": 7200
})))
.expect(1)
.mount(&server)
.await;
let config = test_config(&server);
let tokens = config.exchange_code("test-code", verifier()).await.unwrap();
assert_eq!(tokens.access_token, "test-access-token");
assert_eq!(tokens.refresh_token.as_deref(), Some("test-refresh-token"));
let expires_at = tokens.expires_at.expect("expires_at should be set");
let expected = Utc::now() + chrono::Duration::seconds(7200);
assert!((expires_at - expected).num_seconds().abs() < 5);
}
#[tokio::test]
async fn exchange_code_handles_missing_refresh_token_and_expiry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "test-access-token",
"token_type": "bearer"
})))
.expect(1)
.mount(&server)
.await;
let config = test_config(&server);
let tokens = config.exchange_code("test-code", verifier()).await.unwrap();
assert_eq!(tokens.access_token, "test-access-token");
assert_eq!(tokens.refresh_token, None);
assert_eq!(tokens.expires_at, None);
}
#[tokio::test]
async fn exchange_code_returns_oauth_error_on_failure() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": "invalid_grant",
"error_description": "The code passed is incorrect or expired."
})))
.expect(1)
.mount(&server)
.await;
let config = test_config(&server);
let result = config.exchange_code("test-code", verifier()).await;
assert!(matches!(result, Err(Error::OAuth(_))));
}
#[tokio::test]
async fn refresh_token_returns_tokens() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "new-access-token",
"token_type": "bearer",
"expires_in": 3600
})))
.expect(1)
.mount(&server)
.await;
let config = test_config(&server);
let tokens = config.refresh_token("test-refresh-token").await.unwrap();
assert_eq!(tokens.access_token, "new-access-token");
assert_eq!(tokens.refresh_token, None);
assert!(tokens.expires_at.is_some());
}
}