Skip to main content

shopify_api/
auth.rs

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}