1use 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
12const EXPIRY_SKEW: Duration = Duration::from_secs(60);
14
15#[derive(Clone)]
24pub enum Auth {
25 PrivateIntegration(SecretString),
27 AccessToken(SecretString),
29 OAuth(OAuthAuth),
31}
32
33impl std::fmt::Debug for Auth {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 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 pub fn private_integration(token: impl Into<String>) -> Self {
47 Auth::PrivateIntegration(SecretString::from(token.into()))
48 }
49
50 pub fn access_token(token: impl Into<String>) -> Self {
52 Auth::AccessToken(SecretString::from(token.into()))
53 }
54
55 pub fn oauth(config: OAuthConfig, store: Arc<dyn TokenStore>) -> Self {
57 Auth::OAuth(OAuthAuth::new(config, store))
58 }
59
60 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum UserType {
72 Location,
74 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#[derive(Clone)]
89#[allow(missing_docs)] pub struct OAuthConfig {
91 pub client_id: String,
92 pub client_secret: SecretString,
93 pub user_type: UserType,
94}
95
96impl OAuthConfig {
97 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#[derive(Clone)]
113pub struct TokenSet {
114 access_token: SecretString,
115 refresh_token: SecretString,
116 expires_at: SystemTime,
117}
118
119impl TokenSet {
120 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 pub fn access_token(&self) -> &str {
135 self.access_token.expose_secret()
136 }
137
138 pub fn refresh_token(&self) -> &str {
140 self.refresh_token.expose_secret()
141 }
142
143 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#[async_trait::async_trait]
169pub trait TokenStore: Send + Sync {
170 async fn load(&self) -> Result<Option<TokenSet>>;
172 async fn save(&self, tokens: TokenSet) -> Result<()>;
174}
175
176#[derive(Default)]
178pub struct MemoryTokenStore {
179 tokens: RwLock<Option<TokenSet>>,
180}
181
182impl MemoryTokenStore {
183 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#[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#[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 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 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 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}