Skip to main content

secrets_engine_dropbox/
lib.rs

1//! Dropbox access tokens, brokered from a stored refresh token.
2//!
3//! Dropbox offers no API that mints a sub-credential, so this engine cannot be
4//! anything but a broker: it holds the one long-lived refresh token per
5//! authorisation and hands out four-hour access tokens. The consumer never
6//! sees the durable secret, which is the whole of the benefit.
7//!
8//! The consequence worth internalising before reading further: because nothing
9//! is mintable, **isolation between consumers comes from separate OAuth
10//! authorisations, not from this engine**. One config document per consumer.
11//! Share one authorisation across several consumers and you lose the ability
12//! to revoke any of them independently — there is no server-side trick that
13//! recovers it.
14//!
15//! See `docs/delegation/dropbox.md` for the mechanism and
16//! `docs/delegation/setup/dropbox.md` for the operator walkthrough.
17
18use async_trait::async_trait;
19use base64::prelude::{BASE64_STANDARD, Engine as _};
20use chrono::Utc;
21use secrets_core::engine::{
22    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
23    SecretsEngine, TtlDoc,
24};
25use secrets_core::lease::Lease;
26use secrets_core::mount::ConfigRoleStore;
27use secrets_core::storage::StorageBackend;
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use uuid::Uuid;
31
32const STORE: ConfigRoleStore = ConfigRoleStore::new("dropbox/config/", "dropbox/roles/");
33const MOUNT: &str = "dropbox/creds/";
34const TOKEN_ENDPOINT: &str = "https://api.dropbox.com/oauth2/token";
35const REVOKE_ENDPOINT: &str = "https://api.dropboxapi.com/2/auth/token/revoke";
36
37/// Dropbox fixes access tokens at four hours and offers no way to shorten
38/// them, so this is documentation rather than a setting. The value actually
39/// used for a lease comes from the token response.
40const ACCESS_TOKEN_TTL_SECONDS: i64 = 14400;
41
42/// One consumer's OAuth authorisation. All three fields are durable secrets:
43/// the refresh token never expires and never rotates on use, so this document
44/// is the blast radius of the mount and is never read back out (see
45/// `ConfigRoleStore`).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DropboxConfig {
48    pub app_key: String,
49    pub app_secret: String,
50    /// Obtained once, interactively, with `token_access_type=offline`. Without
51    /// that parameter Dropbox issues no refresh token at all.
52    pub refresh_token: String,
53}
54
55/// What one consumer may ask for. Nothing here can widen the authorisation —
56/// `scopes` only ever narrows it, and the `select_*` fields merely name whom to
57/// act as, which is not the same as being limited to them.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RoleConfig {
60    /// Which `dropbox/config/{name}` authorisation to broker from.
61    pub target: String,
62    /// A subset of the scopes the authorisation already holds. Empty means the
63    /// full set it was granted.
64    #[serde(default)]
65    pub scopes: Vec<String>,
66    /// Team member id to act as, sent by the consumer as
67    /// `Dropbox-API-Select-User`. Only meaningful for a team authorisation.
68    #[serde(default)]
69    pub select_user: Option<String>,
70    /// Acts over team-owned content, sent as `Dropbox-API-Select-Admin`.
71    #[serde(default)]
72    pub select_admin: Option<String>,
73}
74
75#[derive(Debug, Deserialize)]
76struct TokenResponse {
77    access_token: String,
78    expires_in: i64,
79    /// Present when the grant was downscoped; echoed back so the consumer sees
80    /// what it actually got rather than what the role asked for.
81    #[serde(default)]
82    scope: Option<String>,
83}
84
85#[derive(Default)]
86pub struct DropboxEngine {
87    http: reqwest::Client,
88}
89
90impl DropboxEngine {
91    pub fn new() -> Self {
92        Self {
93            http: reqwest::Client::new(),
94        }
95    }
96
97    /// Dropbox authenticates the refresh grant with the app credentials in an
98    /// HTTP Basic header. Built by hand rather than with `reqwest`'s helper so
99    /// the encoding is directly testable — getting this wrong fails as an
100    /// opaque `invalid_client` at runtime.
101    fn basic_auth_header(app_key: &str, app_secret: &str) -> String {
102        format!(
103            "Basic {}",
104            BASE64_STANDARD.encode(format!("{app_key}:{app_secret}"))
105        )
106    }
107
108    fn scope_description(role: &RoleConfig) -> Vec<String> {
109        let mut scoped = Vec::new();
110        if role.scopes.is_empty() {
111            scoped.push("scopes:ALL (every scope this authorisation was granted)".to_string());
112        } else {
113            scoped.extend(role.scopes.iter().map(|s| format!("scope:{s}")));
114        }
115        // Spelled out because the blast radius here surprises people: the
116        // header picks whom to act as, it does not confine the token to them.
117        if let Some(member) = &role.select_user {
118            scoped.push(format!(
119                "acting-as:{member} (team token — selects a target, does NOT reduce reach)"
120            ));
121        }
122        if let Some(admin) = &role.select_admin {
123            scoped.push(format!(
124                "acting-as-admin:{admin} (team-owned content — selects a target, does NOT reduce reach)"
125            ));
126        }
127        scoped
128    }
129}
130
131#[async_trait]
132impl SecretsEngine for DropboxEngine {
133    fn doc(&self) -> EngineDoc {
134        EngineDoc {
135            provider: "Dropbox".to_string(),
136            mechanism: "short-lived OAuth access tokens brokered from one stored \
137                        refresh token per consumer authorisation"
138                .to_string(),
139            shape: CredentialShape::RefreshBroker,
140            // False in the sense the field means: revoking the lease does not
141            // kill the token the consumer is holding. The durable half *is*
142            // revocable, but only as the destructive operator action below.
143            revocable: false,
144            revoke_effect: "nothing, deliberately. Dropbox's /2/auth/token/revoke \
145                            invalidates the refresh token together with the access \
146                            token, so revoking on lease expiry would destroy the \
147                            authorisation and require a human to re-consent. The \
148                            reaper therefore only drops our lease record and lets the \
149                            four-hour token lapse. Real revocation is an operator \
150                            action: DELETE dropbox/config/{target}, then POST \
151                            https://api.dropboxapi.com/2/auth/token/revoke by hand."
152                .to_string(),
153            ttl: TtlDoc::fixed(
154                ACCESS_TOKEN_TTL_SECONDS,
155                "Dropbox fixes access tokens at four hours and offers no way to \
156                 shorten them, including for testing. Roles carry no TTL setting. \
157                 This is the longest window in this deployment, so prefer an \
158                 App-folder app to limit what the four hours can reach.",
159            ),
160            scoping: "per role: a subset of the scopes the authorisation already \
161                      holds. Beyond that, scope is fixed by the Dropbox app itself — \
162                      an App-folder app is sandboxed to /Apps/{name}, a Full Dropbox \
163                      app sees everything the user has. There is no per-path scoping."
164                .to_string(),
165            root_credential: "a non-expiring Dropbox refresh token plus the app key \
166                              and secret, at dropbox/config/{target}. It can mint \
167                              access tokens for that authorisation indefinitely, so \
168                              use one authorisation — and one config document — per \
169                              consumer."
170                .to_string(),
171            paths: vec![
172                PathDoc::new(
173                    "dropbox/config/{target}",
174                    &["POST", "GET", "DELETE"],
175                    "sudo",
176                    "register one consumer's app key, secret and refresh token. GET \
177                     reports only whether it is configured — the secrets are never \
178                     returned. DELETE is the first half of a real revocation.",
179                ),
180                PathDoc::new(
181                    "dropbox/roles/{role}",
182                    &["POST", "GET", "DELETE"],
183                    "create / read / sudo",
184                    "define which authorisation a consumer brokers from, the scope \
185                     subset it gets, and any team member to act as",
186                ),
187                PathDoc::new(
188                    "dropbox/creds/{role}",
189                    &["GET"],
190                    "read",
191                    "exchange the stored refresh token for a four-hour access token \
192                     and open a lease",
193                ),
194                PathDoc::new("dropbox/help", &["GET"], "authenticated", "this document"),
195            ],
196            docs_url: Some("docs/delegation/dropbox.md".to_string()),
197            caveats: vec![
198                "The four-hour TTL is fixed. Dropbox offers no way to shorten it, so \
199                 a leaked token is a four-hour problem and the lease expiry cannot \
200                 make it shorter."
201                    .to_string(),
202                "Revoking an access token also kills its refresh token and every \
203                 other token from the same authorisation, so revocation requires a \
204                 human to re-consent. That is why this engine never revokes \
205                 automatically."
206                    .to_string(),
207                "App-folder versus Full Dropbox is chosen when the app is created and \
208                 is immutable afterwards — changing it means a new app and re-linking \
209                 every consumer. Choose App folder unless you are certain."
210                    .to_string(),
211                "There is no per-path scoping beyond App-folder mode. Narrowness comes \
212                 from the app's access type and its scopes, not from anything a role \
213                 can express."
214                    .to_string(),
215                "A team token plus a Dropbox-API-Select-User header can act as ANY \
216                 team member: the header selects a target, it does not reduce what the \
217                 token can reach. A team credential should stay in the server's own \
218                 custody for administrative jobs, not be leased to consumers — give a \
219                 consumer its own per-user authorisation instead."
220                    .to_string(),
221                "Dropbox does not rotate refresh tokens on use, so there is no \
222                 write-back to design for — but equally nothing ages out on its own."
223                    .to_string(),
224            ],
225        }
226    }
227
228    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
229        STORE.handle_read::<RoleConfig>(storage, path).await
230    }
231
232    async fn write(
233        &self,
234        storage: &dyn StorageBackend,
235        path: &str,
236        data: serde_json::Value,
237    ) -> EngineResult<()> {
238        STORE
239            .handle_write::<DropboxConfig, RoleConfig>(storage, path, data)
240            .await
241    }
242
243    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
244        STORE.handle_delete(storage, path).await
245    }
246
247    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
248        STORE.handle_list(storage, prefix).await
249    }
250
251    async fn generate(
252        &self,
253        storage: &dyn StorageBackend,
254        role_name: &str,
255    ) -> EngineResult<GeneratedCredential> {
256        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
257        let config: DropboxConfig = STORE.require_config(storage, &role.target).await?;
258
259        let mut form = vec![
260            ("grant_type", "refresh_token".to_string()),
261            ("refresh_token", config.refresh_token.clone()),
262        ];
263        // Dropbox accepts `scope` on the refresh grant to request a subset of
264        // what was authorised. It can only narrow, so passing it is safe.
265        if !role.scopes.is_empty() {
266            form.push(("scope", role.scopes.join(" ")));
267        }
268
269        let now = Utc::now();
270        let response = self
271            .http
272            .post(TOKEN_ENDPOINT)
273            .header(
274                reqwest::header::AUTHORIZATION,
275                Self::basic_auth_header(&config.app_key, &config.app_secret),
276            )
277            .form(&form)
278            .send()
279            .await
280            .map_err(|e| EngineError::Provider(format!("Dropbox request failed: {e}")))?;
281
282        let status = response.status();
283        let text = response.text().await.unwrap_or_default();
284        if !status.is_success() {
285            return Err(EngineError::Provider(format!(
286                "Dropbox returned {status} for the refresh grant: {text}"
287            )));
288        }
289        let token: TokenResponse = serde_json::from_str(&text)
290            .map_err(|e| EngineError::Provider(format!("unexpected Dropbox response: {e}")))?;
291
292        let expires_at = now + chrono::Duration::seconds(token.expires_in);
293        let lease = Lease {
294            id: Uuid::new_v4(),
295            // Set by the HTTP handler, which knows the requesting token.
296            token_id_hash: String::new(),
297            engine_mount: MOUNT.to_string(),
298            // The access token is deliberately NOT stored. Keeping it would put
299            // a one-call, authorisation-destroying revocation within reach of
300            // the reaper, and nothing here needs it: see `revoke` below.
301            internal_data: json!({
302                "role": role_name,
303                "target": role.target,
304                "access_token_stored": false,
305            }),
306            issued_at: now,
307            // Dropbox's own figure rather than our constant, so the lease can
308            // never outlive the credential if Dropbox ever changes it.
309            expires_at,
310        };
311
312        let mut data = json!({
313            "access_token": token.access_token,
314            "expires_at": expires_at,
315            "granted_scopes": token.scope,
316        });
317        // Surfaced so the consumer knows which header to send; without it a
318        // team-scoped token acts as the team, not the intended member.
319        if let Some(member) = &role.select_user {
320            data["dropbox_api_select_user"] = json!(member);
321        }
322        if let Some(admin) = &role.select_admin {
323            data["dropbox_api_select_admin"] = json!(admin);
324        }
325
326        Ok(GeneratedCredential::new(
327            data,
328            lease,
329            Self::scope_description(&role),
330        ))
331    }
332
333    async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
334        // Intentionally a no-op. Dropbox revokes an access token and its
335        // refresh token as a set, so calling the revoke endpoint here would
336        // break the consumer's *next* request and need a human to re-consent —
337        // a far worse outcome than letting a four-hour token lapse. Returning
338        // Ok lets the reaper clean up the lease record, which is all it can
339        // honestly do.
340        tracing::warn!(
341            lease_id = %lease.id,
342            revoke_endpoint = REVOKE_ENDPOINT,
343            "dropbox lease revoked locally only: the access token keeps working until \
344             it expires. Revoking it at Dropbox would also destroy the refresh token \
345             and require re-consent, so that is left to a deliberate operator action."
346        );
347        Ok(())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn role(scopes: &[&str], select_user: Option<&str>) -> RoleConfig {
356        RoleConfig {
357            target: "report-service".to_string(),
358            scopes: scopes.iter().map(|s| s.to_string()).collect(),
359            select_user: select_user.map(|s| s.to_string()),
360            select_admin: None,
361        }
362    }
363
364    #[test]
365    fn basic_auth_header_encodes_key_and_secret() {
366        let header = DropboxEngine::basic_auth_header("app-key", "app-secret");
367        let encoded = header.strip_prefix("Basic ").expect("Basic prefix");
368        let decoded = BASE64_STANDARD.decode(encoded).expect("valid base64");
369        assert_eq!(String::from_utf8(decoded).unwrap(), "app-key:app-secret");
370    }
371
372    /// A colon in the secret must not shift the field boundary, which is the
373    /// classic way a hand-built Basic header goes subtly wrong.
374    #[test]
375    fn basic_auth_header_keeps_the_first_colon_as_the_separator() {
376        let header = DropboxEngine::basic_auth_header("key", "sec:ret");
377        let encoded = header.strip_prefix("Basic ").unwrap();
378        let decoded = String::from_utf8(BASE64_STANDARD.decode(encoded).unwrap()).unwrap();
379        let (user, pass) = decoded.split_once(':').unwrap();
380        assert_eq!(user, "key");
381        assert_eq!(pass, "sec:ret");
382    }
383
384    #[test]
385    fn scope_description_lists_requested_scopes() {
386        let scoped = DropboxEngine::scope_description(&role(
387            &["files.content.read", "files.metadata.read"],
388            None,
389        ));
390        assert!(scoped.contains(&"scope:files.content.read".to_string()));
391        assert!(scoped.contains(&"scope:files.metadata.read".to_string()));
392        assert!(!scoped.iter().any(|s| s.contains("acting-as")));
393    }
394
395    /// An unscoped role is a footgun, so the `_doc` must say so rather than
396    /// showing an empty list.
397    #[test]
398    fn scope_description_is_explicit_when_unscoped() {
399        let scoped = DropboxEngine::scope_description(&role(&[], None));
400        assert!(scoped.iter().any(|s| s.contains("scopes:ALL")));
401    }
402
403    /// The consumer must be told that Select-User picks a target rather than
404    /// confining the token, because assuming otherwise understates the risk.
405    #[test]
406    fn scope_description_warns_that_select_user_does_not_narrow() {
407        let scoped = DropboxEngine::scope_description(&role(&["files.content.read"], Some("dbmid:abc")));
408        let acting = scoped
409            .iter()
410            .find(|s| s.starts_with("acting-as:"))
411            .expect("select_user should be described");
412        assert!(acting.contains("dbmid:abc"));
413        assert!(acting.contains("does NOT reduce reach"));
414    }
415
416    #[test]
417    fn select_admin_is_described_separately() {
418        let mut r = role(&[], None);
419        r.select_admin = Some("dbmid:admin".to_string());
420        let scoped = DropboxEngine::scope_description(&r);
421        assert!(scoped.iter().any(|s| s.starts_with("acting-as-admin:")));
422    }
423
424    #[test]
425    fn ttl_is_documented_as_fixed_at_four_hours() {
426        let doc = DropboxEngine::new().doc();
427        assert!(doc.ttl.fixed, "Dropbox cannot shorten its token lifetime");
428        assert_eq!(doc.ttl.min_seconds, Some(ACCESS_TOKEN_TTL_SECONDS));
429        assert_eq!(doc.ttl.max_seconds, Some(ACCESS_TOKEN_TTL_SECONDS));
430    }
431
432    #[test]
433    fn doc_agrees_with_its_shape() {
434        let doc = DropboxEngine::new().doc();
435        assert_eq!(doc.shape, CredentialShape::RefreshBroker);
436        assert_eq!(doc.revocable, doc.shape.revocable());
437    }
438
439    /// The whole point of this engine is that the reaper must not call
440    /// Dropbox's revoke endpoint, so the documented effect has to say so.
441    #[test]
442    fn revoke_effect_warns_that_revocation_is_manual_and_destructive() {
443        let doc = DropboxEngine::new().doc();
444        assert!(doc.revoke_effect.contains("refresh token"));
445        assert!(doc.revoke_effect.contains("re-consent"));
446    }
447
448    #[test]
449    fn token_response_parses_a_dropbox_payload() {
450        let token: TokenResponse = serde_json::from_str(
451            r#"{"access_token":"sl.abc","token_type":"bearer","expires_in":14400,
452                "scope":"files.content.read"}"#,
453        )
454        .expect("should parse");
455        assert_eq!(token.access_token, "sl.abc");
456        assert_eq!(token.expires_in, 14400);
457        assert_eq!(token.scope.as_deref(), Some("files.content.read"));
458    }
459
460    /// `scope` is absent unless the grant was downscoped, so its absence must
461    /// not fail the exchange.
462    #[test]
463    fn token_response_parses_without_a_scope_field() {
464        let token: TokenResponse =
465            serde_json::from_str(r#"{"access_token":"sl.x","token_type":"bearer","expires_in":14400}"#)
466                .expect("should parse");
467        assert!(token.scope.is_none());
468    }
469}