Skip to main content

sharepoint_cli/auth/
device_code.rs

1//! Device-code flow against `login.microsoftonline.com/<tenant>/oauth2/v2.0/`.
2//!
3//! Polling state machine handles all the cases the spec calls out:
4//! - 200 OK → success
5//! - 400 authorization_pending → keep polling at the same interval
6//! - 400 slow_down → bump interval by +5s
7//! - 400 bad_verification_code → keep polling (transient)
8//! - 400 authorization_declined / expired_token / access_denied → terminal failure
9//! - any other 4xx/5xx → terminal failure with body in message
10//!
11//! Polling budget tracks scheduled sleep time only; real wall clock can exceed
12//! `expires_in` if requests are slow. The server's `expired_token` response is
13//! the authoritative cap.
14
15use std::time::Duration;
16
17use base64::Engine;
18use serde::Deserialize;
19use tokio::time::sleep;
20
21use crate::error::{CliError, Result};
22
23#[derive(Debug, Clone, Deserialize)]
24pub struct DeviceCodeResponse {
25    pub device_code: String,
26    pub user_code: String,
27    pub verification_uri: String,
28    pub expires_in: u64,
29    pub interval: u64,
30}
31
32#[derive(Debug, Clone)]
33pub struct TokenResponse {
34    pub access_token: String,
35    pub refresh_token: String,
36    pub id_token: String,
37    pub expires_in: u64,
38    pub scope: String,
39}
40
41/// Identity claims we extract from the id_token (`oid`, `tid`,
42/// `preferred_username`, `name`).
43#[derive(Debug, Clone)]
44pub struct IdClaims {
45    pub oid: String,
46    pub tid: String,
47    pub preferred_username: String,
48    pub name: String,
49}
50
51#[derive(Deserialize)]
52struct RawTokenSuccess {
53    access_token: String,
54    refresh_token: Option<String>,
55    id_token: Option<String>,
56    expires_in: u64,
57    scope: Option<String>,
58}
59
60#[derive(Deserialize)]
61struct RawTokenError {
62    error: String,
63    error_description: Option<String>,
64}
65
66pub async fn request_device_code(
67    client: &reqwest::Client,
68    login_endpoint: &str,
69    tenant: &str,
70    client_id: &str,
71    scope: &str,
72) -> Result<DeviceCodeResponse> {
73    let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/devicecode");
74    let resp = client
75        .post(&url)
76        .form(&[("client_id", client_id), ("scope", scope)])
77        .send()
78        .await?;
79    if !resp.status().is_success() {
80        let status = resp.status().as_u16();
81        let body = resp.text().await.unwrap_or_default();
82        return Err(CliError::Auth(format!(
83            "device-code request failed ({status}): {body}"
84        )));
85    }
86    let parsed: DeviceCodeResponse = resp.json().await?;
87    Ok(parsed)
88}
89
90pub async fn poll_for_token(
91    client: &reqwest::Client,
92    login_endpoint: &str,
93    tenant: &str,
94    client_id: &str,
95    device_code: &str,
96    initial_interval: u64,
97    expires_in: u64,
98) -> Result<TokenResponse> {
99    let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/token");
100    let mut interval = initial_interval.max(1);
101    let mut elapsed: u64 = 0;
102    loop {
103        if elapsed >= expires_in {
104            return Err(CliError::Auth(
105                "device code expired before sign-in completed; try again".into(),
106            ));
107        }
108        sleep(Duration::from_secs(interval)).await;
109        elapsed = elapsed.saturating_add(interval);
110
111        let resp = client
112            .post(&url)
113            .form(&[
114                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
115                ("client_id", client_id),
116                ("device_code", device_code),
117            ])
118            .send()
119            .await?;
120
121        let status = resp.status();
122        let body = resp.text().await.unwrap_or_default();
123
124        if status.is_success() {
125            let raw: RawTokenSuccess = serde_json::from_str(&body).map_err(|e| {
126                CliError::Auth(format!("token response was not JSON: {e}; body={body}"))
127            })?;
128            return Ok(TokenResponse {
129                access_token: raw.access_token,
130                refresh_token: raw
131                    .refresh_token
132                    .ok_or_else(|| CliError::Auth("no refresh_token returned".into()))?,
133                id_token: raw.id_token.ok_or_else(|| {
134                    CliError::Auth("no id_token returned (need 'openid' scope)".into())
135                })?,
136                expires_in: raw.expires_in,
137                scope: raw.scope.unwrap_or_default(),
138            });
139        }
140
141        // Non-200: classify the OAuth error code.
142        let parsed: std::result::Result<RawTokenError, _> = serde_json::from_str(&body);
143        match parsed {
144            Ok(err) => match err.error.as_str() {
145                "authorization_pending" | "bad_verification_code" => {}
146                "slow_down" => {
147                    interval = interval.saturating_add(5);
148                }
149                "authorization_declined" => {
150                    return Err(CliError::Auth("user declined the sign-in request".into()));
151                }
152                "expired_token" => {
153                    return Err(CliError::Auth(
154                        "device code expired before sign-in completed; try again".into(),
155                    ));
156                }
157                "access_denied" => {
158                    if err
159                        .error_description
160                        .as_deref()
161                        .unwrap_or("")
162                        .contains("AADSTS65001")
163                    {
164                        return Err(CliError::Auth(
165                            "admin consent required for this app in your tenant; \
166                             ask your IT admin to grant consent for sharepoint-cli, \
167                             then try again. Details: AADSTS65001"
168                                .into(),
169                        ));
170                    }
171                    return Err(CliError::Auth(format!(
172                        "access denied: {}",
173                        err.error_description.unwrap_or_default()
174                    )));
175                }
176                other => {
177                    return Err(CliError::Auth(format!(
178                        "device-code polling failed: {other}: {}",
179                        err.error_description.unwrap_or_default()
180                    )));
181                }
182            },
183            Err(_) => {
184                return Err(CliError::Auth(format!(
185                    "device-code polling failed ({status}): {body}"
186                )));
187            }
188        }
189    }
190}
191
192pub async fn refresh(
193    client: &reqwest::Client,
194    login_endpoint: &str,
195    tenant: &str,
196    client_id: &str,
197    refresh_token: &str,
198    scope: &str,
199) -> Result<TokenResponse> {
200    let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/token");
201    let resp = client
202        .post(&url)
203        .form(&[
204            ("grant_type", "refresh_token"),
205            ("client_id", client_id),
206            ("refresh_token", refresh_token),
207            ("scope", scope),
208        ])
209        .send()
210        .await?;
211    let status = resp.status();
212    let body = resp.text().await.unwrap_or_default();
213    if status.is_success() {
214        let raw: RawTokenSuccess = serde_json::from_str(&body).map_err(|e| {
215            CliError::Auth(format!("refresh response was not JSON: {e}; body={body}"))
216        })?;
217        return Ok(TokenResponse {
218            access_token: raw.access_token,
219            refresh_token: raw
220                .refresh_token
221                .unwrap_or_else(|| refresh_token.to_string()),
222            id_token: raw.id_token.unwrap_or_default(),
223            expires_in: raw.expires_in,
224            scope: raw.scope.unwrap_or_default(),
225        });
226    }
227    let parsed: std::result::Result<RawTokenError, _> = serde_json::from_str(&body);
228    match parsed {
229        Ok(err) if err.error == "invalid_grant" => Err(CliError::Auth(
230            "refresh token is no longer valid; run `sharepoint auth login`".into(),
231        )),
232        Ok(err) => Err(CliError::Auth(format!(
233            "refresh failed: {}: {}",
234            err.error,
235            err.error_description.unwrap_or_default()
236        ))),
237        Err(_) => Err(CliError::Auth(format!("refresh failed ({status}): {body}"))),
238    }
239}
240
241/// Decode the middle segment of a JWT (no signature verification — we trust
242/// the channel the token came over, like every other MSAL-style client).
243pub fn decode_id_token(id_token: &str) -> Result<IdClaims> {
244    let mid = id_token
245        .split('.')
246        .nth(1)
247        .ok_or_else(|| CliError::Auth("id_token has no payload segment".into()))?;
248    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
249        .decode(mid)
250        .map_err(|e| CliError::Auth(format!("id_token base64 decode: {e}")))?;
251    let json: serde_json::Value = serde_json::from_slice(&bytes)
252        .map_err(|e| CliError::Auth(format!("id_token JSON decode: {e}")))?;
253    let oid = json
254        .get("oid")
255        .and_then(|v| v.as_str())
256        .ok_or_else(|| CliError::Auth("id_token missing 'oid' claim".into()))?;
257    let tid = json
258        .get("tid")
259        .and_then(|v| v.as_str())
260        .ok_or_else(|| CliError::Auth("id_token missing 'tid' claim".into()))?;
261    let preferred_username = json
262        .get("preferred_username")
263        .and_then(|v| v.as_str())
264        .unwrap_or("")
265        .to_string();
266    let name = json
267        .get("name")
268        .and_then(|v| v.as_str())
269        .unwrap_or("")
270        .to_string();
271    Ok(IdClaims {
272        oid: oid.into(),
273        tid: tid.into(),
274        preferred_username,
275        name,
276    })
277}
278
279/// Build the full scope string we request in v0.1.
280pub fn default_scope(read_only: bool) -> &'static str {
281    if read_only {
282        "openid profile offline_access User.Read Files.Read.All Sites.Read.All"
283    } else {
284        "openid profile offline_access User.Read Files.ReadWrite.All Sites.Read.All"
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use base64::Engine;
292
293    fn make_id_token(payload: &serde_json::Value) -> String {
294        let header = "{}";
295        let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header);
296        let body_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
297            .encode(serde_json::to_vec(payload).unwrap());
298        format!("{header_b64}.{body_b64}.sig")
299    }
300
301    #[test]
302    fn decode_id_token_extracts_required_claims() {
303        let token = make_id_token(&serde_json::json!({
304            "oid": "OID-123",
305            "tid": "TID-456",
306            "preferred_username": "alice@contoso.com",
307            "name": "Alice"
308        }));
309        let claims = decode_id_token(&token).unwrap();
310        assert_eq!(claims.oid, "OID-123");
311        assert_eq!(claims.tid, "TID-456");
312        assert_eq!(claims.preferred_username, "alice@contoso.com");
313        assert_eq!(claims.name, "Alice");
314    }
315
316    #[test]
317    fn decode_id_token_errors_when_oid_missing() {
318        let token = make_id_token(&serde_json::json!({"tid": "T"}));
319        assert!(decode_id_token(&token).is_err());
320    }
321
322    #[test]
323    fn default_scope_includes_files_readwrite_when_not_readonly() {
324        assert!(default_scope(false).contains("Files.ReadWrite.All"));
325        assert!(!default_scope(false).contains("Files.Read.All "));
326    }
327
328    #[test]
329    fn default_scope_uses_files_read_when_readonly() {
330        assert!(default_scope(true).contains("Files.Read.All"));
331        assert!(!default_scope(true).contains("Files.ReadWrite.All"));
332    }
333}