onedrive_api/
auth.rs

1use std::fmt;
2
3use crate::{
4    error::{Error, Result},
5    util::handle_oauth2_error_response,
6};
7use reqwest::Client;
8use serde::Deserialize;
9use url::Url;
10
11/// A list of the Microsoft Graph permissions that you want the user to consent to.
12///
13/// # See also
14/// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/permissions-reference#files-permissions)
15#[derive(Clone, Copy, Debug, Default)]
16pub struct Permission {
17    write: bool,
18    access_shared: bool,
19    offline_access: bool,
20}
21
22impl Permission {
23    /// Create a read-only permission.
24    ///
25    /// Note that the permission is at least to allow reading.
26    #[must_use]
27    pub fn new_read() -> Self {
28        Self::default()
29    }
30
31    /// Set the write permission.
32    #[must_use]
33    pub fn write(mut self, write: bool) -> Self {
34        self.write = write;
35        self
36    }
37
38    /// Set the permission to the shared files.
39    #[must_use]
40    pub fn access_shared(mut self, access_shared: bool) -> Self {
41        self.access_shared = access_shared;
42        self
43    }
44
45    /// Set whether allows offline access.
46    ///
47    /// This permission is required to get a [`TokenResponse::refresh_token`] for long time access.
48    ///
49    /// # See also
50    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/permissions-reference#delegated-permissions-21)
51    #[must_use]
52    pub fn offline_access(mut self, offline_access: bool) -> Self {
53        self.offline_access = offline_access;
54        self
55    }
56
57    #[must_use]
58    #[rustfmt::skip]
59    fn to_scope_string(self) -> String {
60        format!(
61            "{}{}{}",
62            if self.write { "files.readwrite" } else { "files.read" },
63            if self.access_shared { ".all" } else { "" },
64            if self.offline_access { " offline_access" } else { "" },
65        )
66    }
67}
68
69/// Control who can sign into the application.
70///
71/// It must match the target audience configuration of registered application.
72///
73/// See: <https://learn.microsoft.com/en-us/graph/auth-v2-user?tabs=http#parameters>
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub enum Tenant {
76    /// For both Microsoft accounts and work or school accounts.
77    ///
78    /// # Notes
79    ///
80    /// This is only allowed for application with type `AzureADandPersonalMicrosoftAccount`
81    /// (Accounts in any organizational directory (Any Microsoft Entra directory - Multitenant) and
82    /// personal Microsoft accounts (e.g. Skype, Xbox)). If the corresponding application by
83    /// Client ID does not have this type, authentications will fail unconditionally.
84    ///
85    /// See:
86    /// <https://learn.microsoft.com/en-us/entra/identity-platform/supported-accounts-validation>
87    Common,
88    /// For work or school accounts only.
89    Organizations,
90    /// For Microsoft accounts only.
91    Consumers,
92    /// Tenant identifiers such as the tenant ID or domain name.
93    ///
94    /// See: <https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints>
95    Issuer(String),
96}
97
98impl Tenant {
99    fn to_issuer(&self) -> &str {
100        match self {
101            Tenant::Common => "common",
102            Tenant::Organizations => "organizations",
103            Tenant::Consumers => "consumers",
104            Tenant::Issuer(s) => s,
105        }
106    }
107}
108
109/// OAuth2 authentication and authorization basics for Microsoft Graph.
110///
111/// # See also
112/// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth/auth-concepts?view=graph-rest-1.0)
113#[derive(Debug, Clone)]
114pub struct Auth {
115    client: Client,
116    client_id: String,
117    permission: Permission,
118    redirect_uri: String,
119    tenant: Tenant,
120}
121
122impl Auth {
123    /// Create an new instance for OAuth2 to Microsoft Graph
124    /// with specified client identifier and permission.
125    pub fn new(
126        client_id: impl Into<String>,
127        permission: Permission,
128        redirect_uri: impl Into<String>,
129        tenant: Tenant,
130    ) -> Self {
131        Self::new_with_client(Client::new(), client_id, permission, redirect_uri, tenant)
132    }
133
134    /// Same as [`Auth::new`][auth_new] but with custom `reqwest::Client`.
135    ///
136    /// [auth_new]: #method.new
137    pub fn new_with_client(
138        client: Client,
139        client_id: impl Into<String>,
140        permission: Permission,
141        redirect_uri: impl Into<String>,
142        tenant: Tenant,
143    ) -> Self {
144        Self {
145            client,
146            client_id: client_id.into(),
147            permission,
148            redirect_uri: redirect_uri.into(),
149            tenant,
150        }
151    }
152
153    /// Get the `client` used to create this instance.
154    #[must_use]
155    pub fn client(&self) -> &Client {
156        &self.client
157    }
158
159    /// Get the `client_id` used to create this instance.
160    #[must_use]
161    pub fn client_id(&self) -> &str {
162        &self.client_id
163    }
164
165    /// Get the `permission` used to create this instance.
166    #[must_use]
167    pub fn permission(&self) -> &Permission {
168        &self.permission
169    }
170
171    /// Get the `redirect_uri` used to create this instance.
172    #[must_use]
173    pub fn redirect_uri(&self) -> &str {
174        &self.redirect_uri
175    }
176
177    /// Get the `tenant` used to create this instance.
178    #[must_use]
179    pub fn tenant(&self) -> &Tenant {
180        &self.tenant
181    }
182
183    #[must_use]
184    fn endpoint_url(&self, endpoint: &str) -> Url {
185        let mut url = Url::parse("https://login.microsoftonline.com").unwrap();
186        url.path_segments_mut().unwrap().extend([
187            self.tenant.to_issuer(),
188            "oauth2",
189            "v2.0",
190            endpoint,
191        ]);
192        url
193    }
194
195    /// Get the URL for web browser for code flow.
196    ///
197    /// # See also
198    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0#authorization-request)
199    #[must_use]
200    pub fn code_auth_url(&self) -> Url {
201        let mut url = self.endpoint_url("authorize");
202        url.query_pairs_mut()
203            .append_pair("client_id", &self.client_id)
204            .append_pair("scope", &self.permission.to_scope_string())
205            .append_pair("redirect_uri", &self.redirect_uri)
206            .append_pair("response_type", "code");
207        url
208    }
209
210    async fn request_token<'a>(
211        &self,
212        require_refresh: bool,
213        params: impl Iterator<Item = (&'a str, &'a str)>,
214    ) -> Result<TokenResponse> {
215        let url = self.endpoint_url("token");
216        let params = params.collect::<Vec<_>>();
217        let resp = self.client.post(url).form(&params).send().await?;
218
219        // Handle special error response.
220        let token_resp: TokenResponse = handle_oauth2_error_response(resp).await?.json().await?;
221
222        if require_refresh && token_resp.refresh_token.is_none() {
223            return Err(Error::unexpected_response("Missing field `refresh_token`"));
224        }
225
226        Ok(token_resp)
227    }
228
229    /// Login using a code.
230    ///
231    /// # See also
232    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0#3-get-a-token)
233    pub async fn login_with_code(
234        &self,
235        code: &str,
236        client_credential: &ClientCredential,
237    ) -> Result<TokenResponse> {
238        self.request_token(
239            self.permission.offline_access,
240            [
241                ("client_id", &self.client_id as &str),
242                ("code", code),
243                ("grant_type", "authorization_code"),
244                ("redirect_uri", &self.redirect_uri),
245            ]
246            .into_iter()
247            .chain(client_credential.params()),
248        )
249        .await
250    }
251
252    /// Login using a refresh token.
253    ///
254    /// This requires [`offline_access`][offline_access], and will **ALWAYS** return
255    /// a new [`refresh_token`][refresh_token] if success.
256    ///
257    /// # Panics
258    /// Panic if the current [`Auth`][auth] is created with no
259    /// [`offline_access`][offline_access] permission.
260    ///
261    /// # See also
262    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0#5-use-the-refresh-token-to-get-a-new-access-token)
263    ///
264    /// [auth]: ./struct.Auth.html
265    /// [offline_access]: ./struct.Permission.html#method.offline_access
266    /// [refresh_token]: ./struct.TokenResponse.html#structfield.refresh_token
267    pub async fn login_with_refresh_token(
268        &self,
269        refresh_token: &str,
270        client_credential: &ClientCredential,
271    ) -> Result<TokenResponse> {
272        assert!(
273            self.permission.offline_access,
274            "Refresh token requires offline_access permission."
275        );
276
277        self.request_token(
278            true,
279            [
280                ("client_id", &self.client_id as &str),
281                ("grant_type", "refresh_token"),
282                ("redirect_uri", &self.redirect_uri),
283                ("refresh_token", refresh_token),
284            ]
285            .into_iter()
286            .chain(client_credential.params()),
287        )
288        .await
289    }
290}
291
292/// Credential of client for code redeemption.
293///
294/// See:
295/// <https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#redeem-a-code-for-an-access-token>
296#[derive(Default, Clone, PartialEq, Eq)]
297#[non_exhaustive]
298pub enum ClientCredential {
299    /// Nothing.
300    ///
301    /// This is the usual case for non-confidential native apps.
302    #[default]
303    None,
304    /// The application secret that you created in the app registration portal for your app.
305    ///
306    /// Don't use the application secret in a native app or single page app because a
307    /// `client_secret` can't be reliably stored on devices or web pages.
308    ///
309    /// See:
310    /// <https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-client_secret>
311    Secret(String),
312    /// An assertion, which is a JSON web token (JWT), that you need to create and sign with the
313    /// certificate you registered as credentials for your application.
314    ///
315    /// See:
316    /// <https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential>
317    Assertion(String),
318}
319
320impl fmt::Debug for ClientCredential {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match self {
323            Self::None => write!(f, "None"),
324            Self::Secret(_) => f.debug_struct("Secret").finish_non_exhaustive(),
325            Self::Assertion(_) => f.debug_struct("Assertion").finish_non_exhaustive(),
326        }
327    }
328}
329
330impl ClientCredential {
331    fn params(&self) -> impl Iterator<Item = (&str, &str)> {
332        let (a, b) = match self {
333            ClientCredential::None => (None, None),
334            ClientCredential::Secret(s) => (Some(("client_secret", &**s)), None),
335            ClientCredential::Assertion(s) => (
336                Some((
337                    "client_assertion_type",
338                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
339                )),
340                Some(("client_assertion", &**s)),
341            ),
342        };
343        a.into_iter().chain(b)
344    }
345}
346
347/// Tokens and some additional data returned by a successful authorization.
348///
349/// # See also
350/// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0#token-response)
351#[derive(Clone, Deserialize)]
352#[non_exhaustive]
353pub struct TokenResponse {
354    /// Indicates the token type value. The only type that Azure AD supports is Bearer.
355    pub token_type: String,
356    /// A list of the Microsoft Graph permissions that the `access_token` is valid for.
357    #[serde(deserialize_with = "space_separated_strings")]
358    pub scope: Vec<String>,
359    /// How long the access token is valid (in seconds).
360    #[serde(rename = "expires_in")]
361    pub expires_in_secs: u64,
362    /// The access token used for authorization in requests.
363    ///
364    /// # See also
365    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-overview#what-is-an-access-token-and-how-do-i-use-it)
366    pub access_token: String,
367    /// The refresh token for refreshing (re-get) an access token when the previous one expired.
368    ///
369    /// This is only returned in code auth flow with [`offline_access`][offline_access] permission.
370    ///
371    /// # See also
372    /// [Microsoft Docs](https://docs.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0#5-use-the-refresh-token-to-get-a-new-access-token)
373    ///
374    /// [offline_access]: ./struct.Permission.html#method.offline_access
375    pub refresh_token: Option<String>,
376}
377
378impl fmt::Debug for TokenResponse {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        f.debug_struct("TokenResponse")
381            .field("token_type", &self.token_type)
382            .field("scope", &self.scope)
383            .field("expires_in_secs", &self.expires_in_secs)
384            .finish_non_exhaustive()
385    }
386}
387
388fn space_separated_strings<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
389where
390    D: serde::de::Deserializer<'de>,
391{
392    struct Visitor;
393
394    impl serde::de::Visitor<'_> for Visitor {
395        type Value = Vec<String>;
396
397        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
398            formatter.write_str("space-separated strings")
399        }
400
401        fn visit_str<E>(self, s: &str) -> std::result::Result<Self::Value, E>
402        where
403            E: serde::de::Error,
404        {
405            Ok(s.split(' ').map(Into::into).collect())
406        }
407    }
408
409    deserializer.deserialize_str(Visitor)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn auth_url() {
418        let perm = Permission::new_read().write(true).offline_access(true);
419        let auth = Auth::new(
420            "some-client-id",
421            perm,
422            "http://example.com",
423            Tenant::Consumers,
424        );
425        assert_eq!(
426            auth.code_auth_url().as_str(),
427            "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=some-client-id&scope=files.readwrite+offline_access&redirect_uri=http%3A%2F%2Fexample.com&response_type=code",
428        );
429    }
430}