Skip to main content

xet_client/cas_client/
auth.rs

1use std::fmt::Debug;
2use std::sync::Arc;
3#[cfg(not(target_family = "wasm"))]
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use reqwest_middleware::ClientWithMiddleware;
7use thiserror::Error;
8use tracing::info;
9use xet_runtime::core::XetContext;
10
11use crate::common::auth::CredentialHelper;
12
13#[derive(Debug, Error)]
14#[non_exhaustive]
15pub enum AuthError {
16    #[error("Refresh function: {0} is not callable")]
17    RefreshFunctionNotCallable(String),
18
19    #[error("Token refresh failed: {0}")]
20    TokenRefreshFailure(String),
21}
22
23impl AuthError {
24    pub fn token_refresh_failure(err: impl ToString) -> Self {
25        Self::TokenRefreshFailure(err.to_string())
26    }
27}
28
29/// Seconds before the token expires to refresh
30const REFRESH_BUFFER_SEC: u64 = 30;
31
32/// Helper type for information about an auth token.
33/// Namely, the token itself and expiration time
34pub type TokenInfo = (String, u64);
35
36/// Helper to provide auth tokens to CAS.
37#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
38#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
39pub trait TokenRefresher: Send + Sync {
40    /// Get a new auth token for CAS and the unixtime (in seconds) for expiration
41    async fn refresh(&self) -> Result<TokenInfo, AuthError>;
42}
43
44#[derive(Debug)]
45pub struct NoOpTokenRefresher;
46
47#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
48#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
49impl TokenRefresher for NoOpTokenRefresher {
50    async fn refresh(&self) -> Result<TokenInfo, AuthError> {
51        Ok(("token".to_string(), 0))
52    }
53}
54
55#[derive(Debug)]
56pub struct ErrTokenRefresher;
57
58#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
59#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
60impl TokenRefresher for ErrTokenRefresher {
61    async fn refresh(&self) -> Result<TokenInfo, AuthError> {
62        Err(AuthError::RefreshFunctionNotCallable("Token refresh not expected".to_string()))
63    }
64}
65
66/// Token refresher that fetches a new token by making an authenticated GET request to a URL.
67///
68/// An optional [`CredentialHelper`](crate::common::auth::CredentialHelper) is applied to the
69/// request before it is sent; pass `None` when no additional credentials are needed.
70pub struct DirectRefreshRouteTokenRefresher {
71    ctx: XetContext,
72    refresh_route: String,
73    client: ClientWithMiddleware,
74    cred_helper: Option<Arc<dyn CredentialHelper>>,
75}
76
77impl std::fmt::Debug for DirectRefreshRouteTokenRefresher {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("DirectRefreshRouteTokenRefresher")
80            .field("refresh_route", &self.refresh_route)
81            .finish_non_exhaustive()
82    }
83}
84
85impl DirectRefreshRouteTokenRefresher {
86    pub fn new(
87        ctx: XetContext,
88        refresh_route: impl Into<String>,
89        client: ClientWithMiddleware,
90        cred_helper: Option<Arc<dyn CredentialHelper>>,
91    ) -> Self {
92        Self {
93            ctx,
94            refresh_route: refresh_route.into(),
95            client,
96            cred_helper,
97        }
98    }
99
100    pub async fn get_cas_jwt(&self) -> Result<crate::hub_client::CasJWTInfo, crate::ClientError> {
101        let client = self.client.clone();
102        let refresh_route = self.refresh_route.clone();
103        let cred_helper = self.cred_helper.clone();
104
105        let jwt_info: crate::hub_client::CasJWTInfo =
106            super::retry_wrapper::RetryWrapper::new(self.ctx.clone(), "xet-token")
107                .run_and_extract_json(move || {
108                    let refresh_route = refresh_route.clone();
109                    let client = client.clone();
110                    let cred_helper = cred_helper.clone();
111                    async move {
112                        let req = client
113                            .get(&refresh_route)
114                            .with_extension(crate::common::http_client::Api("xet-token"));
115                        let req = if let Some(helper) = cred_helper {
116                            helper
117                                .fill_credential(req)
118                                .await
119                                .map_err(reqwest_middleware::Error::middleware)?
120                        } else {
121                            req
122                        };
123                        req.send().await
124                    }
125                })
126                .await?;
127
128        Ok(jwt_info)
129    }
130}
131
132#[cfg_attr(not(target_family = "wasm"), async_trait::async_trait)]
133#[cfg_attr(target_family = "wasm", async_trait::async_trait(?Send))]
134impl TokenRefresher for DirectRefreshRouteTokenRefresher {
135    async fn refresh(&self) -> Result<TokenInfo, AuthError> {
136        let jwt_info = self.get_cas_jwt().await.map_err(AuthError::token_refresh_failure)?;
137
138        Ok((jwt_info.access_token, jwt_info.exp))
139    }
140}
141
142/// Shared configuration for token-based auth
143#[derive(Clone)]
144pub struct AuthConfig {
145    /// Initial token to use
146    pub token: String,
147    /// Initial token expiration time in epoch seconds
148    pub token_expiration: u64,
149    /// A function to refresh tokens.
150    pub token_refresher: Arc<dyn TokenRefresher>,
151}
152
153impl Debug for AuthConfig {
154    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155        f.debug_struct("AuthConfig")
156            .field("token", &self.token)
157            .field("token_expiration", &self.token_expiration)
158            .finish_non_exhaustive()
159    }
160}
161
162impl AuthConfig {
163    /// Builds a new AuthConfig from the indicated optional parameters.
164    pub fn maybe_new(
165        token: Option<String>,
166        token_expiry: Option<u64>,
167        token_refresher: Option<Arc<dyn TokenRefresher>>,
168    ) -> Option<Self> {
169        match (token, token_expiry, token_refresher) {
170            // we have a refresher, so use that. Doesn't matter if the token/expiry are set since we can refresh them.
171            (token, expiry, Some(refresher)) => Some(Self {
172                token: token.unwrap_or_default(),
173                token_expiration: expiry.unwrap_or_default(),
174                token_refresher: refresher,
175            }),
176            // Since no refreshing, we instead use the token with some expiration (no expiration means we expect this
177            // token to live forever.
178            (Some(token), expiry, None) => Some(Self {
179                token,
180                token_expiration: expiry.unwrap_or(u64::MAX),
181                token_refresher: Arc::new(ErrTokenRefresher),
182            }),
183            (_, _, _) => None,
184        }
185    }
186}
187
188pub struct TokenProvider {
189    token: String,
190    expiration: u64,
191    refresher: Arc<dyn TokenRefresher>,
192}
193
194impl TokenProvider {
195    pub fn new(cfg: &AuthConfig) -> Self {
196        Self {
197            token: cfg.token.clone(),
198            expiration: cfg.token_expiration,
199            refresher: cfg.token_refresher.clone(),
200        }
201    }
202
203    pub async fn get_valid_token(&mut self) -> Result<String, AuthError> {
204        if self.is_expired() {
205            let (new_token, new_expiry) = self.refresher.refresh().await?;
206            self.token = new_token;
207            self.expiration = new_expiry;
208            info!(new_expiry = new_expiry, "Token refreshed");
209        }
210        Ok(self.token.clone())
211    }
212
213    fn is_expired(&self) -> bool {
214        #[cfg(not(target_family = "wasm"))]
215        let cur_time = SystemTime::now()
216            .duration_since(UNIX_EPOCH)
217            .map(|d| d.as_secs())
218            .unwrap_or(u64::MAX);
219        #[cfg(target_family = "wasm")]
220        let cur_time = web_time::SystemTime::now()
221            .duration_since(web_time::UNIX_EPOCH)
222            .map(|d| d.as_secs())
223            .unwrap_or(u64::MAX);
224        self.expiration <= cur_time + REFRESH_BUFFER_SEC
225    }
226}