use std::time::Duration;
use base64::Engine;
use serde::Deserialize;
use tokio::time::sleep;
use crate::error::{CliError, Result};
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub id_token: String,
pub expires_in: u64,
pub scope: String,
}
#[derive(Debug, Clone)]
pub struct IdClaims {
pub oid: String,
pub tid: String,
pub preferred_username: String,
pub name: String,
}
#[derive(Deserialize)]
struct RawTokenSuccess {
access_token: String,
refresh_token: Option<String>,
id_token: Option<String>,
expires_in: u64,
scope: Option<String>,
}
#[derive(Deserialize)]
struct RawTokenError {
error: String,
error_description: Option<String>,
}
pub async fn request_device_code(
client: &reqwest::Client,
login_endpoint: &str,
tenant: &str,
client_id: &str,
scope: &str,
) -> Result<DeviceCodeResponse> {
let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/devicecode");
let resp = client
.post(&url)
.form(&[("client_id", client_id), ("scope", scope)])
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Auth(format!(
"device-code request failed ({status}): {body}"
)));
}
let parsed: DeviceCodeResponse = resp.json().await?;
Ok(parsed)
}
pub async fn poll_for_token(
client: &reqwest::Client,
login_endpoint: &str,
tenant: &str,
client_id: &str,
device_code: &str,
initial_interval: u64,
expires_in: u64,
) -> Result<TokenResponse> {
let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/token");
let mut interval = initial_interval.max(1);
let mut elapsed: u64 = 0;
loop {
if elapsed >= expires_in {
return Err(CliError::Auth(
"device code expired before sign-in completed; try again".into(),
));
}
sleep(Duration::from_secs(interval)).await;
elapsed = elapsed.saturating_add(interval);
let resp = client
.post(&url)
.form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("client_id", client_id),
("device_code", device_code),
])
.send()
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if status.is_success() {
let raw: RawTokenSuccess = serde_json::from_str(&body).map_err(|e| {
CliError::Auth(format!("token response was not JSON: {e}; body={body}"))
})?;
return Ok(TokenResponse {
access_token: raw.access_token,
refresh_token: raw
.refresh_token
.ok_or_else(|| CliError::Auth("no refresh_token returned".into()))?,
id_token: raw.id_token.ok_or_else(|| {
CliError::Auth("no id_token returned (need 'openid' scope)".into())
})?,
expires_in: raw.expires_in,
scope: raw.scope.unwrap_or_default(),
});
}
let parsed: std::result::Result<RawTokenError, _> = serde_json::from_str(&body);
match parsed {
Ok(err) => match err.error.as_str() {
"authorization_pending" | "bad_verification_code" => {}
"slow_down" => {
interval = interval.saturating_add(5);
}
"authorization_declined" => {
return Err(CliError::Auth("user declined the sign-in request".into()));
}
"expired_token" => {
return Err(CliError::Auth(
"device code expired before sign-in completed; try again".into(),
));
}
"access_denied" => {
if err
.error_description
.as_deref()
.unwrap_or("")
.contains("AADSTS65001")
{
return Err(CliError::Auth(
"admin consent required for this app in your tenant; \
ask your IT admin to grant consent for sharepoint-cli, \
then try again. Details: AADSTS65001"
.into(),
));
}
return Err(CliError::Auth(format!(
"access denied: {}",
err.error_description.unwrap_or_default()
)));
}
other => {
return Err(CliError::Auth(format!(
"device-code polling failed: {other}: {}",
err.error_description.unwrap_or_default()
)));
}
},
Err(_) => {
return Err(CliError::Auth(format!(
"device-code polling failed ({status}): {body}"
)));
}
}
}
}
pub async fn refresh(
client: &reqwest::Client,
login_endpoint: &str,
tenant: &str,
client_id: &str,
refresh_token: &str,
scope: &str,
) -> Result<TokenResponse> {
let url = format!("{login_endpoint}/{tenant}/oauth2/v2.0/token");
let resp = client
.post(&url)
.form(&[
("grant_type", "refresh_token"),
("client_id", client_id),
("refresh_token", refresh_token),
("scope", scope),
])
.send()
.await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if status.is_success() {
let raw: RawTokenSuccess = serde_json::from_str(&body).map_err(|e| {
CliError::Auth(format!("refresh response was not JSON: {e}; body={body}"))
})?;
return Ok(TokenResponse {
access_token: raw.access_token,
refresh_token: raw
.refresh_token
.unwrap_or_else(|| refresh_token.to_string()),
id_token: raw.id_token.unwrap_or_default(),
expires_in: raw.expires_in,
scope: raw.scope.unwrap_or_default(),
});
}
let parsed: std::result::Result<RawTokenError, _> = serde_json::from_str(&body);
match parsed {
Ok(err) if err.error == "invalid_grant" => Err(CliError::Auth(
"refresh token is no longer valid; run `sharepoint auth login`".into(),
)),
Ok(err) => Err(CliError::Auth(format!(
"refresh failed: {}: {}",
err.error,
err.error_description.unwrap_or_default()
))),
Err(_) => Err(CliError::Auth(format!("refresh failed ({status}): {body}"))),
}
}
pub fn decode_id_token(id_token: &str) -> Result<IdClaims> {
let mid = id_token
.split('.')
.nth(1)
.ok_or_else(|| CliError::Auth("id_token has no payload segment".into()))?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(mid)
.map_err(|e| CliError::Auth(format!("id_token base64 decode: {e}")))?;
let json: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| CliError::Auth(format!("id_token JSON decode: {e}")))?;
let oid = json
.get("oid")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Auth("id_token missing 'oid' claim".into()))?;
let tid = json
.get("tid")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Auth("id_token missing 'tid' claim".into()))?;
let preferred_username = json
.get("preferred_username")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = json
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(IdClaims {
oid: oid.into(),
tid: tid.into(),
preferred_username,
name,
})
}
pub fn default_scope(read_only: bool) -> &'static str {
if read_only {
"openid profile offline_access User.Read Files.Read.All Sites.Read.All"
} else {
"openid profile offline_access User.Read Files.ReadWrite.All Sites.Read.All"
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
fn make_id_token(payload: &serde_json::Value) -> String {
let header = "{}";
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header);
let body_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(payload).unwrap());
format!("{header_b64}.{body_b64}.sig")
}
#[test]
fn decode_id_token_extracts_required_claims() {
let token = make_id_token(&serde_json::json!({
"oid": "OID-123",
"tid": "TID-456",
"preferred_username": "alice@contoso.com",
"name": "Alice"
}));
let claims = decode_id_token(&token).unwrap();
assert_eq!(claims.oid, "OID-123");
assert_eq!(claims.tid, "TID-456");
assert_eq!(claims.preferred_username, "alice@contoso.com");
assert_eq!(claims.name, "Alice");
}
#[test]
fn decode_id_token_errors_when_oid_missing() {
let token = make_id_token(&serde_json::json!({"tid": "T"}));
assert!(decode_id_token(&token).is_err());
}
#[test]
fn default_scope_includes_files_readwrite_when_not_readonly() {
assert!(default_scope(false).contains("Files.ReadWrite.All"));
assert!(!default_scope(false).contains("Files.Read.All "));
}
#[test]
fn default_scope_uses_files_read_when_readonly() {
assert!(default_scope(true).contains("Files.Read.All"));
assert!(!default_scope(true).contains("Files.ReadWrite.All"));
}
}