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