1use 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
37const ACCESS_TOKEN_TTL_SECONDS: i64 = 14400;
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct DropboxConfig {
48 pub app_key: String,
49 pub app_secret: String,
50 pub refresh_token: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct RoleConfig {
60 pub target: String,
62 #[serde(default)]
65 pub scopes: Vec<String>,
66 #[serde(default)]
69 pub select_user: Option<String>,
70 #[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 #[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 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 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 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 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 token_id_hash: String::new(),
297 engine_mount: MOUNT.to_string(),
298 internal_data: json!({
302 "role": role_name,
303 "target": role.target,
304 "access_token_stored": false,
305 }),
306 issued_at: now,
307 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 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 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 #[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 #[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 #[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 #[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 #[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}