use std::sync::Arc;
use std::time::{Duration, SystemTime};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use tokio::sync::{Mutex, RwLock};
use crate::error::{Error, Result};
const EXPIRY_SKEW: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub enum Auth {
PrivateIntegration(SecretString),
AccessToken(SecretString),
OAuth(OAuthAuth),
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Auth::PrivateIntegration(_) => f.write_str("Auth::PrivateIntegration(REDACTED)"),
Auth::AccessToken(_) => f.write_str("Auth::AccessToken(REDACTED)"),
Auth::OAuth(_) => f.write_str("Auth::OAuth(REDACTED)"),
}
}
}
impl Auth {
pub fn private_integration(token: impl Into<String>) -> Self {
Auth::PrivateIntegration(SecretString::from(token.into()))
}
pub fn access_token(token: impl Into<String>) -> Self {
Auth::AccessToken(SecretString::from(token.into()))
}
pub fn oauth(config: OAuthConfig, store: Arc<dyn TokenStore>) -> Self {
Auth::OAuth(OAuthAuth::new(config, store))
}
pub(crate) async fn bearer(&self, http: &reqwest::Client, base_url: &str) -> Result<String> {
match self {
Auth::PrivateIntegration(t) | Auth::AccessToken(t) => Ok(t.expose_secret().to_owned()),
Auth::OAuth(oauth) => oauth.bearer(http, base_url).await,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserType {
Location,
Company,
}
impl UserType {
fn as_str(self) -> &'static str {
match self {
UserType::Location => "Location",
UserType::Company => "Company",
}
}
}
#[derive(Clone)]
#[allow(missing_docs)] pub struct OAuthConfig {
pub client_id: String,
pub client_secret: SecretString,
pub user_type: UserType,
}
impl OAuthConfig {
pub fn new(
client_id: impl Into<String>,
client_secret: impl Into<String>,
user_type: UserType,
) -> Self {
Self {
client_id: client_id.into(),
client_secret: SecretString::from(client_secret.into()),
user_type,
}
}
}
#[derive(Clone)]
pub struct TokenSet {
access_token: SecretString,
refresh_token: SecretString,
expires_at: SystemTime,
}
impl TokenSet {
pub fn new(
access_token: impl Into<String>,
refresh_token: impl Into<String>,
expires_at: SystemTime,
) -> Self {
Self {
access_token: SecretString::from(access_token.into()),
refresh_token: SecretString::from(refresh_token.into()),
expires_at,
}
}
pub fn access_token(&self) -> &str {
self.access_token.expose_secret()
}
pub fn refresh_token(&self) -> &str {
self.refresh_token.expose_secret()
}
pub fn expires_at(&self) -> SystemTime {
self.expires_at
}
fn is_fresh(&self) -> bool {
SystemTime::now() + EXPIRY_SKEW < self.expires_at
}
}
impl std::fmt::Debug for TokenSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenSet")
.field("access_token", &"REDACTED")
.field("refresh_token", &"REDACTED")
.field("expires_at", &self.expires_at)
.finish()
}
}
#[async_trait::async_trait]
pub trait TokenStore: Send + Sync {
async fn load(&self) -> Result<Option<TokenSet>>;
async fn save(&self, tokens: TokenSet) -> Result<()>;
}
#[derive(Default)]
pub struct MemoryTokenStore {
tokens: RwLock<Option<TokenSet>>,
}
impl MemoryTokenStore {
pub fn new(initial: TokenSet) -> Self {
Self {
tokens: RwLock::new(Some(initial)),
}
}
}
#[async_trait::async_trait]
impl TokenStore for MemoryTokenStore {
async fn load(&self) -> Result<Option<TokenSet>> {
Ok(self.tokens.read().await.clone())
}
async fn save(&self, tokens: TokenSet) -> Result<()> {
*self.tokens.write().await = Some(tokens);
Ok(())
}
}
#[derive(Clone)]
pub struct OAuthAuth {
config: OAuthConfig,
store: Arc<dyn TokenStore>,
cache: Arc<RwLock<Option<TokenSet>>>,
refresh_lock: Arc<Mutex<()>>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
expires_in: u64,
}
impl OAuthAuth {
fn new(config: OAuthConfig, store: Arc<dyn TokenStore>) -> Self {
Self {
config,
store,
cache: Arc::new(RwLock::new(None)),
refresh_lock: Arc::new(Mutex::new(())),
}
}
async fn bearer(&self, http: &reqwest::Client, base_url: &str) -> Result<String> {
if let Some(tokens) = self.cache.read().await.as_ref() {
if tokens.is_fresh() {
return Ok(tokens.access_token().to_owned());
}
}
let _guard = self.refresh_lock.lock().await;
if let Some(tokens) = self.cache.read().await.as_ref() {
if tokens.is_fresh() {
return Ok(tokens.access_token().to_owned());
}
}
let current = match self.store.load().await? {
Some(t) => t,
None => {
return Err(Error::Auth(
"no OAuth tokens in the token store; complete the install flow first \
(exchange the authorization code, then `TokenStore::save` the result)"
.into(),
))
}
};
if current.is_fresh() {
let token = current.access_token().to_owned();
*self.cache.write().await = Some(current);
return Ok(token);
}
tracing::debug!("refreshing GoHighLevel OAuth access token");
let response = http
.post(format!("{base_url}/oauth/token"))
.form(&[
("client_id", self.config.client_id.as_str()),
("client_secret", self.config.client_secret.expose_secret()),
("grant_type", "refresh_token"),
("refresh_token", current.refresh_token()),
("user_type", self.config.user_type.as_str()),
])
.send()
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(Error::Auth(format!(
"token refresh failed ({status}): {body}"
)));
}
let parsed: TokenResponse = response
.json()
.await
.map_err(|e| Error::Auth(format!("token refresh returned an unexpected body: {e}")))?;
let tokens = TokenSet::new(
parsed.access_token,
parsed.refresh_token,
SystemTime::now() + Duration::from_secs(parsed.expires_in),
);
self.store.save(tokens.clone()).await?;
let access = tokens.access_token().to_owned();
*self.cache.write().await = Some(tokens);
Ok(access)
}
}