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(¶ms).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}