Skip to main content

ghl_sdk/
auth.rs

1//! Authentication: Private Integration Tokens and OAuth 2.0 with automatic refresh.
2
3use std::sync::Arc;
4use std::time::{Duration, SystemTime};
5
6use secrecy::{ExposeSecret, SecretString};
7use serde::Deserialize;
8use tokio::sync::{Mutex, RwLock};
9
10use crate::error::{Error, Result};
11
12/// Refresh the access token this long before it actually expires.
13const EXPIRY_SKEW: Duration = Duration::from_secs(60);
14
15/// How the client authenticates against the API.
16///
17/// Pick one of:
18/// - [`Auth::private_integration`] — a `pit-…` token created in a sub-account's
19///   *Settings → Private Integrations*. The simplest option for internal tools.
20/// - [`Auth::access_token`] — a raw OAuth access token you obtained yourself
21///   (no refresh handling; the token is used as-is).
22/// - [`Auth::oauth`] — full OAuth 2.0 with automatic refresh via a [`TokenStore`].
23#[derive(Clone)]
24pub enum Auth {
25    /// Private Integration Token (`pit-…`).
26    PrivateIntegration(SecretString),
27    /// A raw bearer token supplied by the caller; never refreshed.
28    AccessToken(SecretString),
29    /// OAuth 2.0 marketplace-app credentials with automatic refresh.
30    OAuth(OAuthAuth),
31}
32
33impl std::fmt::Debug for Auth {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        // Never print token material.
36        match self {
37            Auth::PrivateIntegration(_) => f.write_str("Auth::PrivateIntegration(REDACTED)"),
38            Auth::AccessToken(_) => f.write_str("Auth::AccessToken(REDACTED)"),
39            Auth::OAuth(_) => f.write_str("Auth::OAuth(REDACTED)"),
40        }
41    }
42}
43
44impl Auth {
45    /// Authenticate with a Private Integration Token (`pit-…`).
46    pub fn private_integration(token: impl Into<String>) -> Self {
47        Auth::PrivateIntegration(SecretString::from(token.into()))
48    }
49
50    /// Authenticate with a pre-obtained OAuth access token (no refresh).
51    pub fn access_token(token: impl Into<String>) -> Self {
52        Auth::AccessToken(SecretString::from(token.into()))
53    }
54
55    /// Authenticate with OAuth 2.0 credentials and automatic token refresh.
56    pub fn oauth(config: OAuthConfig, store: Arc<dyn TokenStore>) -> Self {
57        Auth::OAuth(OAuthAuth::new(config, store))
58    }
59
60    /// Resolve the current bearer token, refreshing if needed.
61    pub(crate) async fn bearer(&self, http: &reqwest::Client, base_url: &str) -> Result<String> {
62        match self {
63            Auth::PrivateIntegration(t) | Auth::AccessToken(t) => Ok(t.expose_secret().to_owned()),
64            Auth::OAuth(oauth) => oauth.bearer(http, base_url).await,
65        }
66    }
67}
68
69/// Whether the OAuth token belongs to a sub-account (location) or an agency (company).
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum UserType {
72    /// A sub-account token.
73    Location,
74    /// An agency-level token (can be exchanged for location tokens).
75    Company,
76}
77
78impl UserType {
79    fn as_str(self) -> &'static str {
80        match self {
81            UserType::Location => "Location",
82            UserType::Company => "Company",
83        }
84    }
85}
86
87/// OAuth 2.0 app credentials (from the GoHighLevel Marketplace developer portal).
88#[derive(Clone)]
89#[allow(missing_docs)] // fields match the OAuth token request parameters
90pub struct OAuthConfig {
91    pub client_id: String,
92    pub client_secret: SecretString,
93    pub user_type: UserType,
94}
95
96impl OAuthConfig {
97    /// Build an OAuth config from app credentials.
98    pub fn new(
99        client_id: impl Into<String>,
100        client_secret: impl Into<String>,
101        user_type: UserType,
102    ) -> Self {
103        Self {
104            client_id: client_id.into(),
105            client_secret: SecretString::from(client_secret.into()),
106            user_type,
107        }
108    }
109}
110
111/// An access/refresh token pair with its expiry instant.
112#[derive(Clone)]
113pub struct TokenSet {
114    access_token: SecretString,
115    refresh_token: SecretString,
116    expires_at: SystemTime,
117}
118
119impl TokenSet {
120    /// Build a token set (e.g. from an authorization-code exchange you ran yourself).
121    pub fn new(
122        access_token: impl Into<String>,
123        refresh_token: impl Into<String>,
124        expires_at: SystemTime,
125    ) -> Self {
126        Self {
127            access_token: SecretString::from(access_token.into()),
128            refresh_token: SecretString::from(refresh_token.into()),
129            expires_at,
130        }
131    }
132
133    /// The current access token (handle with care — this exposes the secret).
134    pub fn access_token(&self) -> &str {
135        self.access_token.expose_secret()
136    }
137
138    /// The current refresh token (handle with care — this exposes the secret).
139    pub fn refresh_token(&self) -> &str {
140        self.refresh_token.expose_secret()
141    }
142
143    /// When the access token expires.
144    pub fn expires_at(&self) -> SystemTime {
145        self.expires_at
146    }
147
148    fn is_fresh(&self) -> bool {
149        SystemTime::now() + EXPIRY_SKEW < self.expires_at
150    }
151}
152
153impl std::fmt::Debug for TokenSet {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.debug_struct("TokenSet")
156            .field("access_token", &"REDACTED")
157            .field("refresh_token", &"REDACTED")
158            .field("expires_at", &self.expires_at)
159            .finish()
160    }
161}
162
163/// Persistence for OAuth tokens (memory, file, Redis, Postgres, …).
164///
165/// GoHighLevel rotates the refresh token on every use, so implementations
166/// **must** persist what [`TokenStore::save`] hands them or the integration
167/// will lose its session on restart.
168#[async_trait::async_trait]
169pub trait TokenStore: Send + Sync {
170    /// Load the most recently saved tokens, if any.
171    async fn load(&self) -> Result<Option<TokenSet>>;
172    /// Persist a freshly rotated token set. Must be durable before returning.
173    async fn save(&self, tokens: TokenSet) -> Result<()>;
174}
175
176/// In-memory token store — fine for single-process tools; tokens are lost on restart.
177#[derive(Default)]
178pub struct MemoryTokenStore {
179    tokens: RwLock<Option<TokenSet>>,
180}
181
182impl MemoryTokenStore {
183    /// Seed the store with an initial token set.
184    pub fn new(initial: TokenSet) -> Self {
185        Self {
186            tokens: RwLock::new(Some(initial)),
187        }
188    }
189}
190
191#[async_trait::async_trait]
192impl TokenStore for MemoryTokenStore {
193    async fn load(&self) -> Result<Option<TokenSet>> {
194        Ok(self.tokens.read().await.clone())
195    }
196
197    async fn save(&self, tokens: TokenSet) -> Result<()> {
198        *self.tokens.write().await = Some(tokens);
199        Ok(())
200    }
201}
202
203/// OAuth state: cached tokens + single-flight refresh.
204#[derive(Clone)]
205pub struct OAuthAuth {
206    config: OAuthConfig,
207    store: Arc<dyn TokenStore>,
208    cache: Arc<RwLock<Option<TokenSet>>>,
209    refresh_lock: Arc<Mutex<()>>,
210}
211
212/// Wire shape of `POST /oauth/token` responses.
213#[derive(Deserialize)]
214struct TokenResponse {
215    access_token: String,
216    refresh_token: String,
217    expires_in: u64,
218}
219
220impl OAuthAuth {
221    fn new(config: OAuthConfig, store: Arc<dyn TokenStore>) -> Self {
222        Self {
223            config,
224            store,
225            cache: Arc::new(RwLock::new(None)),
226            refresh_lock: Arc::new(Mutex::new(())),
227        }
228    }
229
230    async fn bearer(&self, http: &reqwest::Client, base_url: &str) -> Result<String> {
231        // Fast path: cached and fresh.
232        if let Some(tokens) = self.cache.read().await.as_ref() {
233            if tokens.is_fresh() {
234                return Ok(tokens.access_token().to_owned());
235            }
236        }
237
238        // Slow path: single-flight refresh with a double-check after acquiring the lock.
239        let _guard = self.refresh_lock.lock().await;
240        if let Some(tokens) = self.cache.read().await.as_ref() {
241            if tokens.is_fresh() {
242                return Ok(tokens.access_token().to_owned());
243            }
244        }
245
246        let current = match self.store.load().await? {
247            Some(t) => t,
248            None => {
249                return Err(Error::Auth(
250                    "no OAuth tokens in the token store; complete the install flow first \
251                     (exchange the authorization code, then `TokenStore::save` the result)"
252                        .into(),
253                ))
254            }
255        };
256        if current.is_fresh() {
257            let token = current.access_token().to_owned();
258            *self.cache.write().await = Some(current);
259            return Ok(token);
260        }
261
262        tracing::debug!("refreshing GoHighLevel OAuth access token");
263        let response = http
264            .post(format!("{base_url}/oauth/token"))
265            .form(&[
266                ("client_id", self.config.client_id.as_str()),
267                ("client_secret", self.config.client_secret.expose_secret()),
268                ("grant_type", "refresh_token"),
269                ("refresh_token", current.refresh_token()),
270                ("user_type", self.config.user_type.as_str()),
271            ])
272            .send()
273            .await?;
274
275        let status = response.status();
276        if !status.is_success() {
277            let body = response.text().await.unwrap_or_default();
278            return Err(Error::Auth(format!(
279                "token refresh failed ({status}): {body}"
280            )));
281        }
282
283        let parsed: TokenResponse = response
284            .json()
285            .await
286            .map_err(|e| Error::Auth(format!("token refresh returned an unexpected body: {e}")))?;
287        let tokens = TokenSet::new(
288            parsed.access_token,
289            parsed.refresh_token,
290            SystemTime::now() + Duration::from_secs(parsed.expires_in),
291        );
292
293        // Persist first (refresh tokens are single-use), then cache.
294        self.store.save(tokens.clone()).await?;
295        let access = tokens.access_token().to_owned();
296        *self.cache.write().await = Some(tokens);
297        Ok(access)
298    }
299}