1use std::{future::Future, pin::Pin};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::{Shopify, ShopifyAPIError};
7
8pub type TokenStoreFuture<'a> =
9 Pin<Box<dyn Future<Output = Result<(), ShopifyAPIError>> + Send + 'a>>;
10
11pub trait TokenStore: Send + Sync {
12 fn save_token<'a>(&'a self, shop: &'a str, token: TokenData) -> TokenStoreFuture<'a>;
13}
14
15#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)]
16pub struct TokenData {
17 pub access_token: String,
18 pub scope: Option<String>,
19 pub expires_at: Option<DateTime<Utc>>,
20 pub refresh_token: Option<String>,
21 pub refresh_token_expires_at: Option<DateTime<Utc>>,
22}
23
24impl std::fmt::Debug for TokenData {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 f.debug_struct("TokenData")
27 .field("access_token", &"<redacted>")
28 .field("scope", &self.scope)
29 .field("expires_at", &self.expires_at)
30 .field(
31 "refresh_token",
32 &self.refresh_token.as_ref().map(|_| "<redacted>"),
33 )
34 .field("refresh_token_expires_at", &self.refresh_token_expires_at)
35 .finish()
36 }
37}
38
39impl TokenData {
40 pub fn never_expiring(access_token: impl Into<String>) -> Self {
41 Self {
42 access_token: access_token.into(),
43 scope: None,
44 expires_at: None,
45 refresh_token: None,
46 refresh_token_expires_at: None,
47 }
48 }
49
50 pub fn expires_within(&self, leeway: chrono::Duration) -> bool {
51 self.expires_at
52 .map(|expires_at| expires_at <= Utc::now() + leeway)
53 .unwrap_or(false)
54 }
55}
56
57#[derive(Clone, Eq, PartialEq)]
58pub enum ShopifyAuth {
59 AccessToken(String),
60 ClientCredentials {
61 client_id: String,
62 client_secret: String,
63 current_token: Option<TokenData>,
64 },
65 ExpiringOfflineToken {
66 client_id: String,
67 client_secret: String,
68 token: TokenData,
69 },
70}
71
72impl std::fmt::Debug for ShopifyAuth {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 ShopifyAuth::AccessToken(_) => {
76 f.debug_tuple("AccessToken").field(&"<redacted>").finish()
77 }
78 ShopifyAuth::ClientCredentials { current_token, .. } => f
79 .debug_struct("ClientCredentials")
80 .field("client_id", &"<redacted>")
81 .field("client_secret", &"<redacted>")
82 .field("current_token", current_token)
83 .finish(),
84 ShopifyAuth::ExpiringOfflineToken { token, .. } => f
85 .debug_struct("ExpiringOfflineToken")
86 .field("client_id", &"<redacted>")
87 .field("client_secret", &"<redacted>")
88 .field("token", token)
89 .finish(),
90 }
91 }
92}
93
94impl ShopifyAuth {
95 pub fn client_credentials(
96 client_id: impl Into<String>,
97 client_secret: impl Into<String>,
98 ) -> Self {
99 Self::ClientCredentials {
100 client_id: client_id.into(),
101 client_secret: client_secret.into(),
102 current_token: None,
103 }
104 }
105
106 pub fn expiring_offline_token(
107 client_id: impl Into<String>,
108 client_secret: impl Into<String>,
109 token: TokenData,
110 ) -> Self {
111 Self::ExpiringOfflineToken {
112 client_id: client_id.into(),
113 client_secret: client_secret.into(),
114 token,
115 }
116 }
117}
118
119#[derive(Debug, Deserialize)]
120struct TokenResponse {
121 access_token: String,
122 scope: Option<String>,
123 expires_in: Option<i64>,
124 refresh_token: Option<String>,
125 refresh_token_expires_in: Option<i64>,
126}
127
128impl TokenResponse {
129 fn into_token_data(self) -> TokenData {
130 TokenData {
131 access_token: self.access_token,
132 scope: self.scope,
133 expires_at: self
134 .expires_in
135 .map(|seconds| Utc::now() + chrono::Duration::seconds(seconds)),
136 refresh_token: self.refresh_token,
137 refresh_token_expires_at: self
138 .refresh_token_expires_in
139 .map(|seconds| Utc::now() + chrono::Duration::seconds(seconds)),
140 }
141 }
142}
143
144impl Shopify {
145 pub async fn access_token(&self) -> Result<String, ShopifyAPIError> {
146 let auth = self
147 .auth
148 .lock()
149 .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?
150 .clone();
151
152 match auth {
153 ShopifyAuth::AccessToken(token) => Ok(token),
154 ShopifyAuth::ClientCredentials {
155 client_id,
156 client_secret,
157 current_token,
158 } => {
159 if let Some(token) = current_token {
160 if !token.expires_within(self.token_refresh_leeway) {
161 return Ok(token.access_token);
162 }
163 }
164
165 let token = self
166 .request_client_credentials_token(&client_id, &client_secret)
167 .await?;
168 self.persist_and_replace_auth(
169 ShopifyAuth::ClientCredentials {
170 client_id,
171 client_secret,
172 current_token: Some(token.clone()),
173 },
174 token.clone(),
175 )
176 .await?;
177 Ok(token.access_token)
178 }
179 ShopifyAuth::ExpiringOfflineToken {
180 client_id,
181 client_secret,
182 token,
183 } => {
184 if !token.expires_within(self.token_refresh_leeway) {
185 return Ok(token.access_token);
186 }
187
188 let refresh_token = token.refresh_token.as_deref().ok_or_else(|| {
189 ShopifyAPIError::Authentication(
190 "expiring offline token is missing refresh_token".to_string(),
191 )
192 })?;
193 let refreshed = self
194 .refresh_token_with_credentials(&client_id, &client_secret, refresh_token)
195 .await?;
196 self.persist_and_replace_auth(
197 ShopifyAuth::ExpiringOfflineToken {
198 client_id,
199 client_secret,
200 token: refreshed.clone(),
201 },
202 refreshed.clone(),
203 )
204 .await?;
205 Ok(refreshed.access_token)
206 }
207 }
208 }
209
210 async fn persist_and_replace_auth(
211 &self,
212 auth: ShopifyAuth,
213 token: TokenData,
214 ) -> Result<(), ShopifyAPIError> {
215 self.replace_auth(auth)?;
216 if let Some(store) = &self.token_store {
217 store.save_token(self.shop_domain(), token).await?;
218 }
219 Ok(())
220 }
221
222 pub async fn request_client_credentials_token(
223 &self,
224 client_id: &str,
225 client_secret: &str,
226 ) -> Result<TokenData, ShopifyAPIError> {
227 let response = self
228 .client()
229 .post(self.token_url())
230 .form(&[
231 ("grant_type", "client_credentials"),
232 ("client_id", client_id),
233 ("client_secret", client_secret),
234 ])
235 .send()
236 .await?
237 .error_for_status()?
238 .json::<TokenResponse>()
239 .await?;
240
241 Ok(response.into_token_data())
242 }
243
244 pub async fn refresh_token_with_credentials(
245 &self,
246 client_id: &str,
247 client_secret: &str,
248 refresh_token: &str,
249 ) -> Result<TokenData, ShopifyAPIError> {
250 let response = self
251 .client()
252 .post(self.token_url())
253 .form(&[
254 ("grant_type", "refresh_token"),
255 ("client_id", client_id),
256 ("client_secret", client_secret),
257 ("refresh_token", refresh_token),
258 ])
259 .send()
260 .await?
261 .error_for_status()?
262 .json::<TokenResponse>()
263 .await?;
264
265 Ok(response.into_token_data())
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn token_expiration_uses_leeway() {
275 let token = TokenData {
276 access_token: "token".to_string(),
277 scope: None,
278 expires_at: Some(Utc::now() + chrono::Duration::minutes(1)),
279 refresh_token: None,
280 refresh_token_expires_at: None,
281 };
282
283 assert!(token.expires_within(chrono::Duration::minutes(5)));
284 assert!(!token.expires_within(chrono::Duration::seconds(1)));
285 }
286
287 #[test]
288 fn static_token_never_expires() {
289 let token = TokenData::never_expiring("token");
290
291 assert!(!token.expires_within(chrono::Duration::days(365)));
292 }
293}