use crate::{Error, Result, OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct OAuthClient {
client_id: String,
client_secret: String,
redirect_uri: String,
http_client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
pub token_type: String,
pub scope: String,
#[serde(default)]
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub token_type: String,
pub scope: String,
}
impl OAuthToken {
pub fn from_response(response: TokenResponse) -> Self {
let expires_at = Utc::now() + Duration::seconds(response.expires_in);
Self {
access_token: response.access_token,
refresh_token: response.refresh_token,
expires_at,
token_type: response.token_type,
scope: response.scope,
}
}
pub fn is_expired(&self) -> bool {
Utc::now() >= self.expires_at
}
pub fn is_expiring_soon(&self) -> bool {
self.is_expiring_within(Duration::minutes(5))
}
pub fn is_expiring_within(&self, duration: Duration) -> bool {
Utc::now() + duration >= self.expires_at
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthErrorResponse {
pub error: String,
#[serde(default)]
pub error_description: Option<String>,
}
pub mod scopes {
pub const IDENTITY: &str = "identity";
pub const IDENTITY_EMAIL: &str = "identity[email]";
pub const IDENTITY_MEMBERSHIPS: &str = "identity.memberships";
pub const CAMPAIGNS: &str = "campaigns";
pub const CAMPAIGNS_MEMBERS: &str = "campaigns.members";
pub const CAMPAIGNS_MEMBERS_EMAIL: &str = "campaigns.members[email]";
pub const CAMPAIGNS_MEMBERS_ADDRESS: &str = "campaigns.members.address";
pub const CAMPAIGNS_POSTS: &str = "campaigns.posts";
pub const CAMPAIGNS_WEBHOOK: &str = "w:campaigns.webhook";
}
impl OAuthClient {
pub fn new(
client_id: impl Into<String>,
client_secret: impl Into<String>,
redirect_uri: impl Into<String>,
) -> Self {
Self {
client_id: client_id.into(),
client_secret: client_secret.into(),
redirect_uri: redirect_uri.into(),
http_client: reqwest::Client::new(),
}
}
pub fn authorization_url(&self, scopes: &[&str]) -> String {
let scope = scopes.join(" ");
format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}",
OAUTH_AUTHORIZE_URL,
urlencoding::encode(&self.client_id),
urlencoding::encode(&self.redirect_uri),
urlencoding::encode(&scope)
)
}
pub fn authorization_url_with_state(&self, scopes: &[&str], state: &str) -> String {
let scope = scopes.join(" ");
format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}",
OAUTH_AUTHORIZE_URL,
urlencoding::encode(&self.client_id),
urlencoding::encode(&self.redirect_uri),
urlencoding::encode(&scope),
urlencoding::encode(state)
)
}
pub async fn exchange_code(&self, code: &str) -> Result<OAuthToken> {
let params = [
("code", code),
("grant_type", "authorization_code"),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
("redirect_uri", &self.redirect_uri),
];
let response = self
.http_client
.post(OAUTH_TOKEN_URL)
.form(¶ms)
.send()
.await?;
if response.status().is_success() {
let token_response: TokenResponse = response.json().await?;
Ok(OAuthToken::from_response(token_response))
} else {
let error: OAuthErrorResponse = response.json().await?;
Err(Error::OAuth {
error: error.error,
description: error.error_description.unwrap_or_default(),
})
}
}
pub async fn refresh_token(&self, refresh_token: &str) -> Result<OAuthToken> {
let params = [
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &self.client_id),
("client_secret", &self.client_secret),
];
let response = self
.http_client
.post(OAUTH_TOKEN_URL)
.form(¶ms)
.send()
.await?;
if response.status().is_success() {
let token_response: TokenResponse = response.json().await?;
Ok(OAuthToken::from_response(token_response))
} else {
let error: OAuthErrorResponse = response.json().await?;
Err(Error::OAuth {
error: error.error,
description: error.error_description.unwrap_or_default(),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_authorization_url() {
let client = OAuthClient::new("test_client_id", "test_secret", "https://example.com/callback");
let url = client.authorization_url(&[scopes::IDENTITY, scopes::IDENTITY_MEMBERSHIPS]);
assert!(url.contains("client_id=test_client_id"));
assert!(url.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"));
assert!(url.contains("scope=identity%20identity.memberships"));
}
#[test]
fn test_authorization_url_with_state() {
let client = OAuthClient::new("test_client_id", "test_secret", "https://example.com/callback");
let url = client.authorization_url_with_state(&[scopes::IDENTITY], "random_state");
assert!(url.contains("state=random_state"));
}
}