1use 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#[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 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
241pub 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
279pub 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}