dropbox_sdk/oauth2.rs
1// Copyright (c) 2019-2025 Dropbox, Inc.
2
3//! Helpers for requesting OAuth2 tokens.
4//!
5//! OAuth2 has a few possible ways to authenticate, and the right choice depends on how your app
6//! operates and is deployed.
7//!
8//! For an overview, see the [Dropbox OAuth Guide].
9//!
10//! For quick recommendations based on the type of app you have, see the [OAuth types summary].
11//!
12//! [Dropbox OAuth Guide]: https://developers.dropbox.com/oauth-guide
13//! [OAuth types summary]: https://developers.dropbox.com/oauth-guide#summary
14
15use std::env;
16use std::io::{self, IsTerminal, Write};
17use std::sync::Arc;
18use async_lock::RwLock;
19use base64::Engine;
20use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD};
21use ring::rand::{SecureRandom, SystemRandom};
22use url::form_urlencoded::Serializer as UrlEncoder;
23use url::Url;
24use crate::Error;
25use crate::async_client_trait::NoauthClient;
26use crate::client_helpers::{parse_response, prepare_request};
27use crate::client_trait_common::{Endpoint, ParamsType, Style};
28
29/// Which type of OAuth2 flow to use.
30#[derive(Debug, Clone)]
31pub enum Oauth2Type {
32 /// The Authorization Code flow yields a temporary authorization code which must be turned into
33 /// an OAuth2 token by making another call. The authorization page can do a web redirect back to
34 /// your app with the code (if it is a server-side app), or can be used without a redirect URI,
35 /// in which case the authorization page displays the authorization code to the user and they
36 /// must then input the code manually into the program.
37 AuthorizationCode {
38 /// Client secret
39 client_secret: String,
40 },
41
42 /// The PKCE flow is an extension of the Authorization Code flow which uses dynamically
43 /// generated codes instead of an app secret to perform the OAuth exchange. This both avoids
44 /// having a hardcoded secret in the app (useful for client-side / mobile apps) and also ensures
45 /// that the authorization code can only be used by the client.
46 PKCE(PkceCode),
47
48 /// In Implicit Grant flow, the authorization page directly includes an OAuth2 token when it
49 /// redirects the user's web browser back to your program, and no separate call to generate a
50 /// token is needed. This can ONLY be used with a redirect URI.
51 ///
52 /// This flow is considered "legacy" and is not as secure as the other flows.
53 ImplicitGrant,
54}
55
56impl Oauth2Type {
57 /// The value to put in the "response_type" parameter to request the given token type.
58 pub(crate) fn response_type_str(&self) -> &'static str {
59 match self {
60 Oauth2Type::AuthorizationCode { .. } | Oauth2Type::PKCE { .. } => "code",
61 Oauth2Type::ImplicitGrant => "token",
62 }
63 }
64}
65
66/// What type of access token is requested? If unsure, ShortLivedAndRefresh is probably what you
67/// want.
68#[derive(Debug, Copy, Clone)]
69pub enum TokenType {
70 /// Return a short-lived bearer token and a long-lived refresh token that can be used to
71 /// generate new bearer tokens in the future (as long as a user's approval remains valid).
72 /// This is the default type for this SDK.
73 ShortLivedAndRefresh,
74
75 /// Return just the short-lived bearer token, without refresh token. The app will have to start
76 /// the authorization flow again to obtain a new token.
77 ShortLived,
78
79 /// Return a long-lived bearer token. The app must be allowed to do this in the Dropbox app
80 /// console. This capability will be removed in the future.
81 #[deprecated]
82 LongLived,
83}
84
85impl TokenType {
86 /// The value to put in the `token_access_type` parameter. If `None`, the parameter is omitted
87 /// entirely.
88 pub(crate) fn token_access_type_str(self) -> Option<&'static str> {
89 match self {
90 TokenType::ShortLivedAndRefresh => Some("offline"),
91 TokenType::ShortLived => Some("online"),
92 #[allow(deprecated)] TokenType::LongLived => None,
93 }
94 }
95}
96
97/// A proof key for OAuth2 PKCE ("Proof Key for Code Exchange") flow.
98#[derive(Debug, Clone)]
99pub struct PkceCode {
100 /// The value of the code key.
101 pub code: String,
102}
103
104impl PkceCode {
105 /// Generate a new random code string.
106 #[allow(clippy::new_without_default)]
107 pub fn new() -> Self {
108 // Spec lets us use [a-zA-Z0-9._~-] as the alphabet, and a length between 43 and 128.
109 // A 93-byte input ends up as 125 base64 characters, so let's do that.
110 let mut bytes = [0u8; 93];
111 // not expecting this to ever actually fail:
112 SystemRandom::new().fill(&mut bytes).expect("failed to get random bytes for PKCE");
113 let code = URL_SAFE.encode(bytes);
114 Self { code }
115 }
116
117 /// Get the SHA-256 hash as a base64-encoded string.
118 pub fn s256(&self) -> String {
119 let digest = ring::digest::digest(&ring::digest::SHA256, self.code.as_bytes());
120 URL_SAFE_NO_PAD.encode(digest.as_ref())
121 }
122}
123
124/// Builds a URL that can be given to the user to visit to have Dropbox authorize your app.
125///
126/// If this app is a server-side app, you should redirect the user's browser to this URL to begin
127/// the authorization, and set the redirect_uri to bring the user back to your site when done.
128///
129/// If this app is a client-side app, you should open a web browser with this URL to begin the
130/// authorization, and set the redirect_uri to bring the user back to your app.
131///
132/// As a special case, if your app is a command-line application, you can skip setting the
133/// redirect_uri and print this URL and instruct the user to open it in a browser. When they
134/// complete the authorization, they will be given an auth code to input back into your app.
135///
136/// If you are using the deprecated Implicit Grant flow, the redirect after authentication will
137/// provide you an OAuth2 token. In all other cases, you will have an authorization code, and you
138/// must call make another call to obtain a token. See [`Authorization`], which is used to do this.
139#[derive(Debug)]
140pub struct AuthorizeUrlBuilder<'a> {
141 client_id: &'a str,
142 flow_type: &'a Oauth2Type,
143 token_type: TokenType,
144 force_reapprove: bool,
145 force_reauthentication: bool,
146 disable_signup: bool,
147 redirect_uri: Option<&'a str>,
148 state: Option<&'a str>,
149 require_role: Option<&'a str>,
150 locale: Option<&'a str>,
151 scope: Option<&'a str>,
152}
153
154impl<'a> AuthorizeUrlBuilder<'a> {
155 /// Return a new builder for the given client ID and auth flow type, with all fields set to
156 /// defaults.
157 pub fn new(client_id: &'a str, flow_type: &'a Oauth2Type) -> Self {
158 Self {
159 client_id,
160 flow_type,
161 token_type: TokenType::ShortLivedAndRefresh,
162 force_reapprove: false,
163 force_reauthentication: false,
164 disable_signup: false,
165 redirect_uri: None,
166 state: None,
167 require_role: None,
168 locale: None,
169 scope: None,
170 }
171 }
172
173 /// Set whether the user should be prompted to approve the request regardless of whether they
174 /// have approved it before.
175 pub fn force_reapprove(mut self, value: bool) -> Self {
176 self.force_reapprove = value;
177 self
178 }
179
180 /// Set whether the user should have to re-login when approving the request.
181 pub fn force_reauthentication(mut self, value: bool) -> Self {
182 self.force_reauthentication = value;
183 self
184 }
185
186 /// Set whether new user signups should be allowed or not while approving the request.
187 pub fn disable_signup(mut self, value: bool) -> Self {
188 self.disable_signup = value;
189 self
190 }
191
192 /// Set the URI the approve request should redirect the user to when completed.
193 /// If no redirect URI is specified, the user will be shown the code directly and will have to
194 /// manually input it into your app.
195 pub fn redirect_uri(mut self, value: &'a str) -> Self {
196 self.redirect_uri = Some(value);
197 self
198 }
199
200 /// Up to 500 bytes of arbitrary data that will be passed back to your redirect URI. This
201 /// parameter should be used to protect against cross-site request forgery (CSRF).
202 pub fn state(mut self, value: &'a str) -> Self {
203 self.state = Some(value);
204 self
205 }
206
207 /// If this parameter is specified, the user will be asked to authorize with a particular type
208 /// of Dropbox account, either `work` for a team account or `personal` for a personal account.
209 /// Your app should still verify the type of Dropbox account after authorization since the user
210 /// could modify or remove the require_role parameter.
211 pub fn require_role(mut self, value: &'a str) -> Self {
212 self.require_role = Some(value);
213 self
214 }
215
216 /// Force a specific locale when prompting the user, instead of the locale indicated by their
217 /// browser.
218 pub fn locale(mut self, value: &'a str) -> Self {
219 self.locale = Some(value);
220 self
221 }
222
223 /// What type of token should be requested. Defaults to [`TokenType::ShortLivedAndRefresh`].
224 pub fn token_type(mut self, value: TokenType) -> Self {
225 self.token_type = value;
226 self
227 }
228
229 /// This parameter allows your user to authorize a subset of the scopes selected in the
230 /// App Console. Multiple scopes are separated by a space. If this parameter is omitted, the
231 /// authorization page will request all scopes selected on the Permissions tab.
232 pub fn scope(mut self, value: &'a str) -> Self {
233 self.scope = Some(value);
234 self
235 }
236
237 /// Build the OAuth2 authorization URL from the previously given parameters.
238 pub fn build(self) -> Url {
239 let mut url = Url::parse("https://www.dropbox.com/oauth2/authorize").unwrap();
240 {
241 let mut params = url.query_pairs_mut();
242 params.append_pair("response_type", self.flow_type.response_type_str());
243 params.append_pair("client_id", self.client_id);
244 if let Some(val) = self.token_type.token_access_type_str() {
245 params.append_pair("token_access_type", val);
246 }
247 if self.force_reapprove {
248 params.append_pair("force_reapprove", "true");
249 }
250 if self.force_reauthentication {
251 params.append_pair("force_reauthentication", "true");
252 }
253 if self.disable_signup {
254 params.append_pair("disable_signup", "true");
255 }
256 if let Some(value) = self.redirect_uri {
257 params.append_pair("redirect_uri", value);
258 }
259 if let Some(value) = self.state {
260 params.append_pair("state", value);
261 }
262 if let Some(value) = self.require_role {
263 params.append_pair("require_role", value);
264 }
265 if let Some(value) = self.locale {
266 params.append_pair("locale", value);
267 }
268 if let Some(value) = self.scope {
269 params.append_pair("scope", value);
270 }
271 if let Oauth2Type::PKCE(code) = self.flow_type {
272 params.append_pair("code_challenge", &code.s256());
273 params.append_pair("code_challenge_method", "S256");
274 }
275 }
276 url
277 }
278}
279
280/// [`Authorization`] is a state-machine.
281///
282/// Every flow starts with the `InitialAuth` state, which is just after the user authorizes the app
283/// and gets redirected back. It then proceeds to either the `Refresh` or `AccessToken` state
284/// depending on whether a long-lived token was requested.
285///
286/// `Refresh` contains the refresh token necessary to obtain updated short-lived access tokens.
287///
288/// `AccessToken` contains just the access token itself, which is either a long-lived access token
289/// not expected to expire, or a short-lived token which, if it expires, cannot be refreshed except
290/// by starting the authorization flow over again.
291#[derive(Debug, Clone)]
292enum AuthorizationState {
293 InitialAuth {
294 flow_type: Oauth2Type,
295 auth_code: String,
296 redirect_uri: Option<String>,
297 },
298 Refresh {
299 refresh_token: String,
300 client_secret: Option<String>,
301 },
302 AccessToken {
303 client_secret: Option<String>,
304 token: String,
305 },
306}
307
308/// Provides for continuing authorization of the app.
309#[derive(Debug, Clone)]
310pub struct Authorization {
311 /// Dropbox app key
312 pub client_id: String,
313 state: AuthorizationState,
314}
315
316impl Authorization {
317 /// Get the client ID for this authorization.
318 pub fn client_id(&self) -> &str {
319 &self.client_id
320 }
321
322 /// Create a new instance using the authorization code provided upon redirect back to your app
323 /// (or via manual user entry if not using a redirect URI) after the user logs in.
324 ///
325 /// Requires the client ID; the type of OAuth2 flow being used (including the client secret or
326 /// the PKCE challenge); the authorization code; and the redirect URI used for the original
327 /// authorization request, if any.
328 pub fn from_auth_code(
329 client_id: String,
330 flow_type: Oauth2Type,
331 auth_code: String,
332 redirect_uri: Option<String>,
333 ) -> Self {
334 Self {
335 client_id,
336 state: AuthorizationState::InitialAuth { flow_type, auth_code, redirect_uri },
337 }
338 }
339
340 /// Save the authorization state to a string which can be reloaded later.
341 ///
342 /// Returns `None` if the state cannot be saved (e.g. authorization has not completed getting a
343 /// token yet).
344 pub fn save(&self) -> Option<String> {
345 match &self.state {
346 AuthorizationState::AccessToken { token, client_secret } if client_secret.is_none() => {
347 // Legacy long-lived access token.
348 Some(format!("1&{}", token))
349 },
350 AuthorizationState::Refresh { refresh_token, .. } => {
351 Some(format!("2&{}", refresh_token))
352 },
353 _ => None,
354 }
355 }
356
357 /// Reload a saved authorization state produced by [`save`](Authorization::save).
358 ///
359 /// Returns `None` if the string could not be recognized. In this case, you should start the
360 /// authorization procedure from scratch.
361 ///
362 /// Note that a loaded authorization state is not necessarily still valid and may produce
363 /// [`Authentication`](crate::Error::Authentication) errors. In such a case you should also
364 /// start the authorization procedure from scratch.
365 pub fn load(client_id: String, saved: &str) -> Option<Self> {
366 Some(match saved.get(0..2) {
367 Some("1&") => {
368 #[allow(deprecated)]
369 Self::from_long_lived_access_token(saved[2..].to_owned())
370 },
371 Some("2&") => Self::from_refresh_token(client_id, saved[2..].to_owned()),
372 _ => {
373 error!("unrecognized saved Authorization representation: {:?}", saved);
374 return None;
375 }
376 })
377 }
378
379 /// Recreate the authorization from a refresh token obtained using the [`Oauth2Type::PKCE`]
380 /// flow.
381 pub fn from_refresh_token(
382 client_id: String,
383 refresh_token: String,
384 ) -> Self {
385 Self {
386 client_id,
387 state: AuthorizationState::Refresh {
388 refresh_token,
389 client_secret: None,
390 },
391 }
392 }
393
394 /// Recreate the authorization from a refresh token obtained using the
395 /// [`Oauth2Type::AuthorizationCode`] flow. This requires the client secret as well.
396 pub fn from_client_secret_refresh_token(
397 client_id: String,
398 client_secret: String,
399 refresh_token: String,
400 ) -> Self {
401 Self {
402 client_id,
403 state: AuthorizationState::Refresh {
404 refresh_token,
405 client_secret: Some(client_secret),
406 },
407 }
408 }
409
410 /// Recreate the authorization from a long-lived access token. This token cannot be refreshed;
411 /// any call to [`obtain_access_token_async`](Authorization::obtain_access_token_async) will
412 /// simply return the given token. Therefore this requires neither client ID or client secret.
413 ///
414 /// Long-lived tokens are deprecated and the ability to generate them will be removed in the
415 /// future.
416 #[deprecated]
417 pub fn from_long_lived_access_token(
418 access_token: String,
419 ) -> Self {
420 Self {
421 client_id: String::new(),
422 state: AuthorizationState::AccessToken { token: access_token, client_secret: None },
423 }
424 }
425
426 if_feature! { "sync_routes",
427 /// Compatibility shim for working with sync HTTP clients.
428 pub fn obtain_access_token(
429 &mut self,
430 sync_client: impl crate::client_trait::NoauthClient
431 ) -> Result<String, Error> {
432 use futures::FutureExt;
433 self.obtain_access_token_async(sync_client)
434 .now_or_never()
435 .expect("sync client future should resolve immediately")
436 }
437 }
438
439 /// Obtain an access token. Use this to complete the authorization process, or to obtain an
440 /// updated token when a short-lived access token has expired.
441 pub async fn obtain_access_token_async(&mut self, client: impl NoauthClient) -> Result<String, Error> {
442 let mut redirect_uri = None;
443 let mut client_secret = None;
444 let mut pkce_code = None;
445 let mut refresh_token = None;
446 let mut auth_code = None;
447
448 match self.state.clone() {
449 AuthorizationState::AccessToken { token, client_secret: secret } => {
450 match secret {
451 None => {
452 // Long-lived token which cannot be refreshed
453 return Ok(token)
454 },
455 Some(secret) => {
456 client_secret = Some(secret);
457 }
458 }
459 }
460 AuthorizationState::InitialAuth {
461 flow_type, auth_code: code, redirect_uri: uri } =>
462 {
463 match flow_type {
464 Oauth2Type::ImplicitGrant => {
465 self.state = AuthorizationState::AccessToken { client_secret: None, token: code.clone() };
466 return Ok(code);
467 }
468 Oauth2Type::AuthorizationCode { client_secret: secret } => {
469 client_secret = Some(secret);
470 }
471 Oauth2Type::PKCE(pkce) => {
472 pkce_code = Some(pkce.code.clone());
473 }
474 }
475 auth_code = Some(code);
476 redirect_uri = uri;
477 }
478 AuthorizationState::Refresh { refresh_token: refresh, client_secret: secret } => {
479 refresh_token = Some(refresh);
480 if let Some(secret) = secret {
481 client_secret = Some(secret);
482 }
483 }
484 }
485
486 let params = {
487 let mut params = UrlEncoder::new(String::new());
488
489 if let Some(refresh) = &refresh_token {
490 params.append_pair("grant_type", "refresh_token");
491 params.append_pair("refresh_token", refresh);
492 } else {
493 params.append_pair("grant_type", "authorization_code");
494 params.append_pair("code", &auth_code.unwrap());
495 }
496
497 params.append_pair("client_id", &self.client_id);
498
499 if let Some(client_secret) = client_secret.as_deref() {
500 params.append_pair("client_secret", client_secret);
501 }
502
503 if let Some(pkce) = &pkce_code {
504 params.append_pair("code_verifier", pkce);
505 }
506
507 if refresh_token.is_none() {
508 if let Some(pkce) = pkce_code {
509 params.append_pair("code_verifier", &pkce);
510 } else {
511 params.append_pair(
512 "client_secret",
513 client_secret.as_ref().expect("need either PKCE code or client secret"));
514 }
515 }
516
517 if let Some(value) = redirect_uri {
518 params.append_pair("redirect_uri", &value);
519 }
520
521 params.finish()
522 };
523
524 let (req, body) = prepare_request(
525 &client,
526 Endpoint::OAuth2,
527 Style::Rpc,
528 "oauth2/token",
529 params,
530 ParamsType::Form,
531 None,
532 None,
533 None,
534 None,
535 None,
536 );
537 let body = body.unwrap_or_default();
538
539 debug!("Requesting OAuth2 token");
540 let resp = client.execute(req, body).await?;
541 let (result_json, _, _) = parse_response(resp, Style::Rpc).await?;
542 let result_value = serde_json::from_str(&result_json)?;
543
544 debug!("OAuth2 response: {:?}", result_value);
545
546 let access_token: String;
547 let refresh_token: Option<String>;
548
549 match result_value {
550 serde_json::Value::Object(mut map) => {
551 match map.remove("access_token") {
552 Some(serde_json::Value::String(token)) => access_token = token,
553 _ => return Err(Error::UnexpectedResponse("no access token in response!".to_owned())),
554 }
555 match map.remove("refresh_token") {
556 Some(serde_json::Value::String(refresh)) => refresh_token = Some(refresh),
557 Some(_) => {
558 return Err(Error::UnexpectedResponse("refresh token is not a string!".to_owned()));
559 },
560 None => refresh_token = None,
561 }
562 },
563 _ => return Err(Error::UnexpectedResponse("response is not a JSON object".to_owned())),
564 }
565
566 match refresh_token {
567 Some(refresh) => {
568 self.state = AuthorizationState::Refresh { refresh_token: refresh, client_secret };
569 }
570 None if !matches!(self.state, AuthorizationState::Refresh {..}) => {
571 self.state = AuthorizationState::AccessToken {
572 token: access_token.clone(),
573 client_secret,
574 };
575 }
576 _ => (),
577 }
578
579 Ok(access_token)
580 }
581}
582
583/// `TokenCache` provides the current OAuth2 token and a means to refresh it in a thread-safe way.
584pub struct TokenCache {
585 auth: RwLock<(Authorization, Arc<String>)>,
586}
587
588impl TokenCache {
589 /// Make a new token cache, using the given [`Authorization`] as a source of tokens.
590 pub fn new(auth: Authorization) -> Self {
591 Self {
592 auth: RwLock::new((auth, Arc::new(String::new()))),
593 }
594 }
595
596 /// Get the current token, unless no cached token is set yet.
597 pub fn get_token(&self) -> Option<Arc<String>> {
598 let read = self.auth.read_blocking();
599 if read.1.is_empty() {
600 None
601 } else {
602 Some(Arc::clone(&read.1))
603 }
604 }
605
606 /// Forces an update to the token, for when it is detected that the token is expired.
607 ///
608 /// To avoid double-updating the token in a race, requires the token which is being replaced.
609 /// For the case where no token is currently present, use the empty string as the token.
610 pub async fn update_token(&self, client: impl NoauthClient, old_token: Arc<String>)
611 -> Result<Arc<String>, Error>
612 {
613 let mut write = self.auth.write().await;
614 // Check if the token changed while we were unlocked; only update it if it
615 // didn't.
616 if write.1 == old_token {
617 write.1 = Arc::new(write.0.obtain_access_token_async(client).await?);
618 }
619 Ok(Arc::clone(&write.1))
620 }
621
622 /// Set the current short-lived token to a specific provided value. Normally it should not be
623 /// necessary to call this function; the token should be obtained automatically using the
624 /// refresh token.
625 pub fn set_access_token(&self, access_token: String) {
626 let mut write = self.auth.write_blocking();
627 write.1 = Arc::new(access_token);
628 }
629}
630
631/// Get an [`Authorization`] instance from environment variables `DBX_CLIENT_ID` and `DBX_OAUTH`
632/// (containing a refresh token) or `DBX_OAUTH_TOKEN` (containing a legacy long-lived token).
633///
634/// If environment variables are not set, and stdin is a terminal, prompt interactively for
635/// authorization.
636///
637/// If environment variables are not set, and stdin is not a terminal, panics.
638///
639/// This is a helper function intended only for tests and example code. Use in production code is
640/// strongly discouraged; you should write something more customized to your needs instead.
641///
642/// In particular, in real production code, you probably don't want to use environment variables.
643/// The client ID should be a hard-coded constant, or specified in configuration somewhere. It is
644/// not something that will change often, or maybe ever.
645/// The refresh token should only be stored somewhere safe like a file or database with restricted
646/// access permissions.
647pub fn get_auth_from_env_or_prompt() -> Authorization {
648 if let Ok(long_lived) = env::var("DBX_OAUTH_TOKEN") {
649 // Used to provide a legacy long-lived token.
650 #[allow(deprecated)]
651 return Authorization::from_long_lived_access_token(long_lived);
652 }
653
654 if let (Ok(client_id), Ok(saved))
655 = (env::var("DBX_CLIENT_ID"), env::var("DBX_OAUTH"))
656 // important! see the above warning about using environment variables for this
657 {
658 match Authorization::load(client_id, &saved) {
659 Some(auth) => return auth,
660 None => {
661 eprintln!("saved authorization in DBX_CLIENT_ID and DBX_OAUTH are invalid");
662 // and fall back to prompting
663 }
664 }
665 }
666
667 if !io::stdin().is_terminal() {
668 panic!("DBX_CLIENT_ID and/or DBX_OAUTH not set, and stdin not a TTY; cannot authorize");
669 }
670
671 fn prompt(msg: &str) -> String {
672 eprint!("{}: ", msg);
673 io::stderr().flush().unwrap();
674 let mut input = String::new();
675 io::stdin().read_line(&mut input).unwrap();
676 input.trim().to_owned()
677 }
678
679 let client_id = prompt("Give me a Dropbox API app key");
680
681 let oauth2_flow = Oauth2Type::PKCE(PkceCode::new());
682 let url = AuthorizeUrlBuilder::new(&client_id, &oauth2_flow)
683 .build();
684 eprintln!("Open this URL in your browser:");
685 eprintln!("{}", url);
686 eprintln!();
687 let auth_code = prompt("Then paste the code here");
688
689 Authorization::from_auth_code(
690 client_id,
691 oauth2_flow,
692 auth_code.trim().to_owned(),
693 None,
694 )
695}