Skip to main content

box_open_sdk/runtime/
auth.rs

1// Code generated by box-gantry (vendored from runtimes/rust/gantryruntime/src/auth.rs). DO NOT EDIT.
2
3//! The Box auth flows, each producing an [`Auth`] passed to
4//! [`Client::new`](super::Client::new). `developer_token` is a fixed token;
5//! `client_credentials` (CCG) and `oauth` exchange credentials at Box's token
6//! endpoint and cache the resulting access token until shortly before it
7//! expires (TR-Rust.5).
8//!
9//! JWT server auth (signing-key assertions) lands in the next runtime slice;
10//! the three flows here cover fixed-token and both refresh-based exchanges.
11
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14
15use tokio::sync::Mutex;
16
17use super::jwt::{JwtConfig, Signer};
18use super::Error;
19
20/// Box's OAuth 2.0 token endpoint, shared by every exchange flow.
21const DEFAULT_TOKEN_URL: &str = "https://api.box.com/oauth2/token";
22
23/// Where a user is sent to grant an OAuth 2.0 app access.
24const AUTHORIZE_URL: &str = "https://account.box.com/api/oauth2/authorize";
25
26/// Refresh a cached token this long before expiry so in-flight requests never
27/// race an expiry.
28const REFRESH_MARGIN: Duration = Duration::from_secs(60);
29
30/// The configured authentication flow. Build one with [`Auth::developer_token`],
31/// [`Auth::client_credentials`], or [`Auth::oauth`], then pass it to
32/// [`Client::new`](super::Client::new).
33pub struct Auth {
34    source: Source,
35}
36
37/// One of the supported token sources.
38enum Source {
39    /// A fixed developer-console token.
40    Developer(String),
41    /// A cached token refreshed by re-posting a fixed grant form (CCG).
42    Form(FormSource),
43    /// A cached token refreshed with a rotating refresh token (OAuth 2.0).
44    OAuth(OAuthSource),
45    /// A cached token refreshed by signing a fresh JWT bearer assertion. Boxed:
46    /// the parsed RSA key makes this variant far larger than the others.
47    Jwt(Box<JwtSource>),
48    /// A caller-supplied [`TokenSource`] (see [`Auth::custom`]).
49    Custom(Arc<dyn TokenSource>),
50}
51
52/// A custom access-token source, for callers that need to own token
53/// acquisition and refresh outside the built-in flows — e.g. an
54/// engine-owned token cache with proactive refresh and per-identity fan-out.
55/// Build an [`Auth`] from one with [`Auth::custom`].
56#[async_trait::async_trait]
57pub trait TokenSource: Send + Sync {
58    /// A valid access token, refreshing through the implementation's own
59    /// cache as needed.
60    async fn access_token(&self) -> Result<String, Error>;
61
62    /// Re-acquire a token after `stale` was rejected by a 401, bypassing any
63    /// cache. If another caller already refreshed past `stale`, return the
64    /// newer token instead of re-acquiring again — single-flight behavior is
65    /// the implementation's responsibility, mirroring the built-in sources.
66    async fn force_refresh(&self, stale: &str) -> Result<String, Error>;
67}
68
69impl Auth {
70    /// The simplest flow: a fixed access token from the Box developer console.
71    pub fn developer_token(token: impl Into<String>) -> Auth {
72        Auth {
73            source: Source::Developer(token.into()),
74        }
75    }
76
77    /// Client Credentials Grant: server-to-server auth with no signing key. Set
78    /// exactly one subject on the config — `enterprise_id` for the service
79    /// account, or `user_id` to act as a managed user.
80    pub fn client_credentials(config: CcgConfig) -> Auth {
81        let (subject_type, subject_id) = match &config.user_id {
82            Some(user) => ("user", user.clone()),
83            None => ("enterprise", config.enterprise_id.clone()),
84        };
85        let form = vec![
86            ("grant_type".to_string(), "client_credentials".to_string()),
87            ("client_id".to_string(), config.client_id),
88            ("client_secret".to_string(), config.client_secret),
89            ("box_subject_type".to_string(), subject_type.to_string()),
90            ("box_subject_id".to_string(), subject_id),
91        ];
92        Auth {
93            source: Source::Form(FormSource {
94                http: auth_http_client(),
95                token_url: config.token_url.unwrap_or_else(default_token_url),
96                form,
97                cached: Mutex::new(Cached::empty()),
98            }),
99        }
100    }
101
102    /// JWT server auth: sign a short-lived RSA assertion (from the app's
103    /// `box_config.json`) and exchange it for an access token. Set exactly one
104    /// subject on the config — `enterprise_id` or `user_id`.
105    ///
106    /// Fallible: the RSA private key is parsed (and if needed decrypted) up
107    /// front, so a bad key fails here rather than on the first request.
108    pub fn jwt(config: JwtConfig) -> Result<Auth, Error> {
109        let signer = Signer::new(&config)?;
110        Ok(Auth {
111            source: Source::Jwt(Box::new(JwtSource {
112                http: auth_http_client(),
113                token_url: config.token_url.unwrap_or_else(default_token_url),
114                client_id: config.client_id,
115                client_secret: config.client_secret,
116                signer,
117                cached: Mutex::new(Cached::empty()),
118            })),
119        })
120    }
121
122    /// Resume the OAuth 2.0 authorization-code flow from a previously stored
123    /// refresh token, exchanging it for access tokens as needed. Box rotates
124    /// the refresh token on each exchange, so the newest one is retained.
125    ///
126    /// The rotated token is held in memory only; use [`Auth::oauth_with_store`]
127    /// to persist each rotation durably across restarts.
128    pub fn oauth(config: OAuthConfig, refresh_token: impl Into<String>) -> Auth {
129        Self::oauth_source(config, refresh_token.into(), None)
130    }
131
132    /// Like [`Auth::oauth`], but persists each rotated refresh token through a
133    /// [`RefreshTokenStore`] before returning — so an app restart reloads the
134    /// live token instead of a refresh token Box has already invalidated.
135    pub fn oauth_with_store(
136        config: OAuthConfig,
137        refresh_token: impl Into<String>,
138        store: Arc<dyn RefreshTokenStore>,
139    ) -> Auth {
140        Self::oauth_source(config, refresh_token.into(), Some(store))
141    }
142
143    /// Build an `Auth` from a custom [`TokenSource`] — for callers that need
144    /// to own token acquisition and refresh outside the built-in flows (e.g.
145    /// an engine-owned token cache with proactive refresh and per-identity
146    /// fan-out) instead of one of the flows above.
147    pub fn custom(source: Arc<dyn TokenSource>) -> Auth {
148        Auth {
149            source: Source::Custom(source),
150        }
151    }
152
153    fn oauth_source(
154        config: OAuthConfig,
155        refresh_token: String,
156        store: Option<Arc<dyn RefreshTokenStore>>,
157    ) -> Auth {
158        Auth {
159            source: Source::OAuth(OAuthSource {
160                http: auth_http_client(),
161                token_url: config.token_url.clone().unwrap_or_else(default_token_url),
162                client_id: config.client_id,
163                client_secret: config.client_secret,
164                store,
165                state: Mutex::new(OAuthState {
166                    token: String::new(),
167                    expiry: Instant::now(),
168                    refresh_token,
169                    // The caller-supplied token is already durable.
170                    refresh_token_persisted: true,
171                }),
172            }),
173        }
174    }
175
176    /// A valid access token for the configured flow.
177    pub(crate) async fn access_token(&self) -> Result<String, Error> {
178        match &self.source {
179            Source::Developer(token) => Ok(token.clone()),
180            Source::Form(source) => source.access_token().await,
181            Source::OAuth(source) => source.access_token().await,
182            Source::Jwt(source) => source.access_token().await,
183            Source::Custom(source) => source.access_token().await,
184        }
185    }
186
187    /// Force-acquire a token after the current one was rejected (a 401),
188    /// bypassing the freshness cache. Single-flight: if another task already
189    /// replaced the rejected `stale` token, that new token is returned instead
190    /// of refreshing again. A fixed developer token has nothing to refresh, so
191    /// it is returned unchanged (the retry then surfaces the 401).
192    pub(crate) async fn force_refresh(&self, stale: &str) -> Result<String, Error> {
193        match &self.source {
194            Source::Developer(token) => Ok(token.clone()),
195            Source::Form(source) => source.force_refresh(stale).await,
196            Source::OAuth(source) => source.force_refresh(stale).await,
197            Source::Jwt(source) => source.force_refresh(stale).await,
198            Source::Custom(source) => source.force_refresh(stale).await,
199        }
200    }
201}
202
203/// A durable store for the rotating OAuth refresh token. Box invalidates the
204/// previous refresh token on each exchange, so an app that restarts must reload
205/// the newest one — implement this to persist each rotation (a file, a DB row,
206/// a secret manager).
207///
208/// `save` is `async` so a store can do real I/O without blocking the executor
209/// (do the I/O with async APIs, or offload sync work via
210/// `tokio::task::spawn_blocking`). It runs before a freshly rotated token is
211/// returned; its failure propagates on the rotating call, and the runtime keeps
212/// retrying persistence on later calls until it succeeds, so a rotation is never
213/// silently treated as durable when it is not.
214#[async_trait::async_trait]
215pub trait RefreshTokenStore: Send + Sync {
216    /// Persist the newly rotated refresh token durably.
217    async fn save(&self, refresh_token: &str) -> Result<(), Error>;
218}
219
220/// The cached token if it is present and not within the refresh margin of
221/// expiry (shared by both caching sources).
222fn fresh_token(token: &str, expiry: Instant) -> Option<String> {
223    if !token.is_empty() && expiry.saturating_duration_since(Instant::now()) > REFRESH_MARGIN {
224        Some(token.to_string())
225    } else {
226        None
227    }
228}
229
230/// A cached access token and its expiry instant.
231struct Cached {
232    token: String,
233    expiry: Instant,
234}
235
236impl Cached {
237    fn empty() -> Cached {
238        Cached {
239            token: String::new(),
240            expiry: Instant::now(),
241        }
242    }
243
244    fn fresh(&self) -> Option<String> {
245        fresh_token(&self.token, self.expiry)
246    }
247
248    fn store(&mut self, token: String, ttl: Duration) {
249        self.expiry = Instant::now() + ttl;
250        self.token = token;
251    }
252}
253
254/// A token source that refreshes by re-posting a fixed grant form (CCG).
255struct FormSource {
256    http: reqwest::Client,
257    token_url: String,
258    form: Vec<(String, String)>,
259    cached: Mutex<Cached>,
260}
261
262impl FormSource {
263    async fn access_token(&self) -> Result<String, Error> {
264        let mut cached = self.cached.lock().await;
265        if let Some(token) = cached.fresh() {
266            return Ok(token);
267        }
268        self.refresh_locked(&mut cached).await
269    }
270
271    async fn force_refresh(&self, stale: &str) -> Result<String, Error> {
272        let mut cached = self.cached.lock().await;
273        // Single-flight: a concurrent 401 may already have replaced the token.
274        if let Some(token) = cached.fresh() {
275            if token != stale {
276                return Ok(token);
277            }
278        }
279        self.refresh_locked(&mut cached).await
280    }
281
282    async fn refresh_locked(&self, cached: &mut Cached) -> Result<String, Error> {
283        let response = post_token_form(&self.http, &self.token_url, &self.form).await?;
284        cached.store(response.access_token.clone(), response.ttl());
285        Ok(response.access_token)
286    }
287}
288
289/// A token source that refreshes by signing a fresh JWT bearer assertion and
290/// exchanging it (server auth with a signing key). Like [`FormSource`], but the
291/// grant form is re-minted each refresh — each assertion is single-use.
292struct JwtSource {
293    http: reqwest::Client,
294    token_url: String,
295    client_id: String,
296    client_secret: String,
297    signer: Signer,
298    cached: Mutex<Cached>,
299}
300
301impl JwtSource {
302    async fn access_token(&self) -> Result<String, Error> {
303        let mut cached = self.cached.lock().await;
304        if let Some(token) = cached.fresh() {
305            return Ok(token);
306        }
307        self.refresh_locked(&mut cached).await
308    }
309
310    async fn force_refresh(&self, stale: &str) -> Result<String, Error> {
311        let mut cached = self.cached.lock().await;
312        // Single-flight: a concurrent 401 may already have replaced the token.
313        if let Some(token) = cached.fresh() {
314            if token != stale {
315                return Ok(token);
316            }
317        }
318        self.refresh_locked(&mut cached).await
319    }
320
321    async fn refresh_locked(&self, cached: &mut Cached) -> Result<String, Error> {
322        let assertion = self.signer.assertion(&self.token_url)?;
323        let form = vec![
324            (
325                "grant_type".to_string(),
326                "urn:ietf:params:oauth:grant-type:jwt-bearer".to_string(),
327            ),
328            ("assertion".to_string(), assertion),
329            ("client_id".to_string(), self.client_id.clone()),
330            ("client_secret".to_string(), self.client_secret.clone()),
331        ];
332        let response = post_token_form(&self.http, &self.token_url, &form).await?;
333        cached.store(response.access_token.clone(), response.ttl());
334        Ok(response.access_token)
335    }
336}
337
338/// The OAuth 2.0 refresh-token source: it rotates the refresh token Box returns
339/// (Box invalidates the old one each exchange) and, when configured, persists
340/// each rotation through a [`RefreshTokenStore`].
341struct OAuthSource {
342    http: reqwest::Client,
343    token_url: String,
344    client_id: String,
345    client_secret: String,
346    store: Option<Arc<dyn RefreshTokenStore>>,
347    state: Mutex<OAuthState>,
348}
349
350struct OAuthState {
351    token: String,
352    expiry: Instant,
353    refresh_token: String,
354    /// Whether `refresh_token` is known durable in the store. The initial token
355    /// came from the caller (already stored); only later rotations start `false`
356    /// until their `save` succeeds.
357    refresh_token_persisted: bool,
358}
359
360impl OAuthSource {
361    async fn access_token(&self) -> Result<String, Error> {
362        let mut state = self.state.lock().await;
363        self.retry_persist(&mut state).await;
364        if let Some(token) = fresh_token(&state.token, state.expiry) {
365            return Ok(token);
366        }
367        self.refresh_locked(&mut state).await
368    }
369
370    async fn force_refresh(&self, stale: &str) -> Result<String, Error> {
371        let mut state = self.state.lock().await;
372        self.retry_persist(&mut state).await;
373        // Single-flight: a concurrent 401 may already have replaced the token.
374        if let Some(token) = fresh_token(&state.token, state.expiry) {
375            if token != stale {
376                return Ok(token);
377            }
378        }
379        self.refresh_locked(&mut state).await
380    }
381
382    /// Best-effort retry of a rotation whose earlier `save` failed, so a
383    /// transient store outage doesn't leave the durable copy behind the live
384    /// token forever. The first failure was already surfaced by `refresh_locked`;
385    /// these retries stay silent so a still-down store can't wedge every call.
386    async fn retry_persist(&self, state: &mut OAuthState) {
387        if state.refresh_token_persisted {
388            return;
389        }
390        match &self.store {
391            Some(store) => {
392                if store.save(&state.refresh_token).await.is_ok() {
393                    state.refresh_token_persisted = true;
394                }
395            }
396            None => state.refresh_token_persisted = true,
397        }
398    }
399
400    async fn refresh_locked(&self, state: &mut OAuthState) -> Result<String, Error> {
401        let form = vec![
402            ("grant_type".to_string(), "refresh_token".to_string()),
403            ("refresh_token".to_string(), state.refresh_token.clone()),
404            ("client_id".to_string(), self.client_id.clone()),
405            ("client_secret".to_string(), self.client_secret.clone()),
406        ];
407        let response = post_token_form(&self.http, &self.token_url, &form).await?;
408        state.token = response.access_token.clone();
409        state.expiry = Instant::now() + response.ttl();
410        if let Some(refresh) = &response.refresh_token {
411            state.refresh_token = refresh.clone();
412            // The rotation isn't durable until the store confirms it. Box has
413            // already invalidated the previous token, so on a save failure we
414            // keep the new token in memory (marked unpersisted, retried on later
415            // calls) but surface the error now.
416            state.refresh_token_persisted = self.store.is_none();
417            if let Some(store) = &self.store {
418                store.save(refresh).await?;
419                state.refresh_token_persisted = true;
420            }
421        }
422        Ok(response.access_token)
423    }
424}
425
426/// The Client Credentials Grant config: server-to-server auth with no signing
427/// key. Set exactly one subject — `enterprise_id` for the service account, or
428/// `user_id` to act as a managed user. Derives `Default` so the optional
429/// `user_id`/`token_url` can be elided with `..Default::default()`.
430#[derive(Clone, Default)]
431pub struct CcgConfig {
432    pub client_id: String,
433    pub client_secret: String,
434    pub enterprise_id: String,
435    /// Optional: act as a managed user instead of the enterprise service account.
436    pub user_id: Option<String>,
437    /// Optional: defaults to Box's token endpoint (custom deployments).
438    pub token_url: Option<String>,
439}
440
441/// The OAuth 2.0 authorization-code config. Use [`OAuthConfig::authorize_url`]
442/// to build the redirect, [`OAuthConfig::exchange_code`] to turn the returned
443/// code into an [`Auth`], or [`Auth::oauth`] to resume from a stored refresh
444/// token.
445#[derive(Clone)]
446pub struct OAuthConfig {
447    pub client_id: String,
448    pub client_secret: String,
449    /// Optional: defaults to Box's token endpoint (custom deployments).
450    pub token_url: Option<String>,
451}
452
453impl OAuthConfig {
454    /// Build the URL to redirect a user to so they can grant access. `state` is
455    /// echoed back to the redirect URI for CSRF protection.
456    pub fn authorize_url(&self, redirect_uri: &str, state: &str) -> String {
457        let query = form_urlencode(&[
458            ("response_type", "code"),
459            ("client_id", &self.client_id),
460            ("redirect_uri", redirect_uri),
461            ("state", state),
462        ]);
463        format!("{AUTHORIZE_URL}?{query}")
464    }
465
466    /// Exchange an authorization code for an [`Auth`] that refreshes itself
467    /// thereafter.
468    pub async fn exchange_code(&self, code: &str, redirect_uri: &str) -> Result<Auth, Error> {
469        let http = auth_http_client();
470        let token_url = self.token_url.clone().unwrap_or_else(default_token_url);
471        let form = vec![
472            ("grant_type".to_string(), "authorization_code".to_string()),
473            ("code".to_string(), code.to_string()),
474            ("client_id".to_string(), self.client_id.clone()),
475            ("client_secret".to_string(), self.client_secret.clone()),
476            ("redirect_uri".to_string(), redirect_uri.to_string()),
477        ];
478        let response = post_token_form(&http, &token_url, &form).await?;
479        let refresh_token = response.refresh_token.clone().ok_or_else(|| {
480            Error::new("gantryruntime: authorization-code exchange returned no refresh_token")
481        })?;
482        let ttl = response.ttl();
483        let source = OAuthSource {
484            http,
485            token_url,
486            client_id: self.client_id.clone(),
487            client_secret: self.client_secret.clone(),
488            store: None,
489            state: Mutex::new(OAuthState {
490                token: response.access_token,
491                expiry: Instant::now() + ttl,
492                refresh_token,
493                refresh_token_persisted: true,
494            }),
495        };
496        Ok(Auth {
497            source: Source::OAuth(source),
498        })
499    }
500}
501
502/// The subset of the token endpoint's JSON response we consume.
503struct TokenResponse {
504    access_token: String,
505    refresh_token: Option<String>,
506    expires_in: u64,
507}
508
509impl TokenResponse {
510    fn ttl(&self) -> Duration {
511        Duration::from_secs(self.expires_in)
512    }
513}
514
515/// POST a form-encoded grant to the token endpoint and decode the response,
516/// surfacing a non-2xx body as the error.
517async fn post_token_form(
518    http: &reqwest::Client,
519    token_url: &str,
520    form: &[(String, String)],
521) -> Result<TokenResponse, Error> {
522    let pairs: Vec<(&str, &str)> = form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
523    let body = form_urlencode(&pairs);
524    let response = http
525        .post(token_url)
526        .header("Content-Type", "application/x-www-form-urlencoded")
527        .header("Accept", "application/json")
528        .body(body)
529        .send()
530        .await?;
531    let status = response.status();
532    let bytes = response.bytes().await?;
533    if !status.is_success() {
534        let detail = String::from_utf8_lossy(&bytes);
535        return Err(Error::new(format!(
536            "gantryruntime: token endpoint returned {}: {}",
537            status.as_u16(),
538            detail.trim()
539        )));
540    }
541    let json: serde_json::Value = serde_json::from_slice(&bytes)?;
542    let access_token = json
543        .get("access_token")
544        .and_then(|v| v.as_str())
545        .map(|s| s.to_string())
546        .filter(|s| !s.is_empty())
547        .ok_or_else(|| Error::new("gantryruntime: token endpoint returned no access_token"))?;
548    Ok(TokenResponse {
549        access_token,
550        refresh_token: json
551            .get("refresh_token")
552            .and_then(|v| v.as_str())
553            .filter(|s| !s.is_empty())
554            .map(|s| s.to_string()),
555        expires_in: json.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(0),
556    })
557}
558
559/// The dedicated auth HTTP client (a shorter timeout than the API client).
560fn auth_http_client() -> reqwest::Client {
561    reqwest::Client::builder()
562        .timeout(Duration::from_secs(30))
563        .build()
564        .unwrap_or_default()
565}
566
567fn default_token_url() -> String {
568    DEFAULT_TOKEN_URL.to_string()
569}
570
571/// Encode key/value pairs as `application/x-www-form-urlencoded`.
572fn form_urlencode(pairs: &[(&str, &str)]) -> String {
573    let mut out = String::new();
574    for (name, value) in pairs {
575        if !out.is_empty() {
576            out.push('&');
577        }
578        percent_encode_into(&mut out, name);
579        out.push('=');
580        percent_encode_into(&mut out, value);
581    }
582    out
583}
584
585/// Percent-encode into `out` per the `application/x-www-form-urlencoded`
586/// unreserved set (spaces become `+`).
587fn percent_encode_into(out: &mut String, value: &str) {
588    for byte in value.bytes() {
589        match byte {
590            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
591                out.push(byte as char)
592            }
593            b' ' => out.push('+'),
594            _ => out.push_str(&format!("%{byte:02X}")),
595        }
596    }
597}