1use std::time::Duration;
4
5use chrono::Utc;
6use serde::Deserialize;
7
8use crate::auth::tokens::TokenSet;
9use crate::error::ClientError;
10
11#[derive(Debug, Clone, Deserialize)]
13pub struct DeviceCodeResponse {
14 pub device_code: String,
15 pub user_code: String,
16 pub verification_uri: String,
17 pub expires_in: u64,
18 pub interval: u64,
19 #[serde(default)]
20 pub message: Option<String>,
21}
22
23#[derive(Debug, Deserialize)]
27pub(crate) struct TokenResponse {
28 pub access_token: String,
29 #[serde(default)]
30 pub refresh_token: Option<String>,
31 #[serde(default)]
32 pub expires_in: Option<u64>,
33 #[serde(default)]
34 pub id_token: Option<String>,
35 #[serde(default)]
37 pub scope: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub(crate) struct ErrorResponse {
42 pub error: String,
43 #[serde(default)]
44 pub error_description: Option<String>,
45}
46
47#[derive(Debug)]
49pub struct PollSuccess {
50 pub tokens: TokenSet,
51 pub id_token: Option<String>,
52}
53
54pub async fn start(
56 client: &reqwest::Client,
57 base_url: &str,
58 client_id: &str,
59 scope: &str,
60) -> Result<DeviceCodeResponse, ClientError> {
61 let url = format!("{base_url}/oauth2/v2.0/devicecode");
62 let resp = client
63 .post(&url)
64 .form(&[("client_id", client_id), ("scope", scope)])
65 .send()
66 .await?;
67
68 let status = resp.status();
69 if !status.is_success() {
70 let text = resp.text().await.unwrap_or_default();
71 return Err(ClientError::Graph {
72 status: status.as_u16(),
73 message: text,
74 });
75 }
76
77 Ok(resp.json::<DeviceCodeResponse>().await?)
78}
79
80pub async fn poll<F, Fut>(
86 client: &reqwest::Client,
87 base_url: &str,
88 client_id: &str,
89 device_code: &str,
90 initial_interval: u64,
91 expires_in: u64,
92 mut sleep: F,
93) -> Result<PollSuccess, ClientError>
94where
95 F: FnMut(Duration) -> Fut,
96 Fut: std::future::Future<Output = ()>,
97{
98 let url = format!("{base_url}/oauth2/v2.0/token");
99 let mut interval = initial_interval;
100 let deadline = Utc::now() + chrono::Duration::seconds(expires_in as i64);
101
102 loop {
103 if Utc::now() >= deadline {
104 return Err(ClientError::DeviceCodeTimeout);
105 }
106 sleep(Duration::from_secs(interval)).await;
107
108 let resp = client
109 .post(&url)
110 .form(&[
111 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
112 ("client_id", client_id),
113 ("device_code", device_code),
114 ])
115 .send()
116 .await?;
117
118 let status = resp.status();
119 let body = resp.bytes().await?;
120
121 if status.is_success() {
122 let tr: TokenResponse = serde_json::from_slice(&body)?;
123 let expires = tr.expires_in.unwrap_or(3600);
124 let refresh_token = tr.refresh_token.ok_or(ClientError::MissingAccessToken)?;
125 return Ok(PollSuccess {
126 tokens: TokenSet {
127 access_token: tr.access_token,
128 refresh_token,
129 expires_at: Utc::now() + chrono::Duration::seconds(expires as i64 - 60),
130 scope: tr.scope,
131 },
132 id_token: tr.id_token,
133 });
134 }
135
136 let err: ErrorResponse = serde_json::from_slice(&body).map_err(|_| ClientError::Graph {
137 status: status.as_u16(),
138 message: String::from_utf8_lossy(&body).into_owned(),
139 })?;
140
141 match err.error.as_str() {
142 "authorization_pending" => continue,
143 "slow_down" => {
144 interval += 5;
145 continue;
146 }
147 "access_denied" => return Err(ClientError::DeviceCodeAccessDenied),
148 "expired_token" => return Err(ClientError::DeviceCodeTimeout),
149 other => {
150 return Err(ClientError::DeviceCodeOther {
151 kind: other.to_string(),
152 description: err.error_description,
153 });
154 }
155 }
156 }
157}
158
159pub async fn real_sleep(d: Duration) {
161 tokio::time::sleep(d).await;
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use wiremock::matchers::{method, path};
168 use wiremock::{Mock, MockServer, ResponseTemplate};
169
170 async fn no_sleep(_: Duration) {}
172
173 #[tokio::test]
174 async fn start_returns_device_code_response() {
175 let server = MockServer::start().await;
176 Mock::given(method("POST"))
177 .and(path("/oauth2/v2.0/devicecode"))
178 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
179 "device_code": "DC123",
180 "user_code": "ABCD-1234",
181 "verification_uri": "https://microsoft.com/devicelogin",
182 "expires_in": 900,
183 "interval": 5,
184 "message": "Please sign in"
185 })))
186 .mount(&server)
187 .await;
188
189 let client = reqwest::Client::new();
190 let resp = start(&client, &server.uri(), "CID", "openid")
191 .await
192 .unwrap();
193 assert_eq!(resp.user_code, "ABCD-1234");
194 assert_eq!(resp.interval, 5);
195 }
196
197 #[tokio::test]
198 async fn poll_returns_tokens_on_success() {
199 let server = MockServer::start().await;
200 Mock::given(method("POST"))
201 .and(path("/oauth2/v2.0/token"))
202 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
203 "access_token": "AT123",
204 "refresh_token": "RT123",
205 "expires_in": 3600,
206 "id_token": "eyJh.eyJ0aWQiOiJ0aWQifQ.sig"
207 })))
208 .mount(&server)
209 .await;
210
211 let client = reqwest::Client::new();
212 let result = poll(&client, &server.uri(), "CID", "DC", 1, 60, no_sleep)
213 .await
214 .unwrap();
215 assert_eq!(result.tokens.access_token, "AT123");
216 assert_eq!(result.tokens.refresh_token, "RT123");
217 assert_eq!(
218 result.id_token.as_deref(),
219 Some("eyJh.eyJ0aWQiOiJ0aWQifQ.sig")
220 );
221 }
222
223 #[tokio::test]
224 async fn poll_retries_on_authorization_pending_then_succeeds() {
225 let server = MockServer::start().await;
226
227 Mock::given(method("POST"))
228 .and(path("/oauth2/v2.0/token"))
229 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
230 "error": "authorization_pending"
231 })))
232 .up_to_n_times(2)
233 .mount(&server)
234 .await;
235
236 Mock::given(method("POST"))
237 .and(path("/oauth2/v2.0/token"))
238 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
239 "access_token": "AT",
240 "refresh_token": "RT",
241 "expires_in": 3600
242 })))
243 .mount(&server)
244 .await;
245
246 let client = reqwest::Client::new();
247 let result = poll(&client, &server.uri(), "CID", "DC", 0, 60, no_sleep)
248 .await
249 .unwrap();
250 assert_eq!(result.tokens.access_token, "AT");
251 }
252
253 #[tokio::test]
254 async fn poll_returns_access_denied_on_user_cancel() {
255 let server = MockServer::start().await;
256 Mock::given(method("POST"))
257 .and(path("/oauth2/v2.0/token"))
258 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
259 "error": "access_denied"
260 })))
261 .mount(&server)
262 .await;
263
264 let client = reqwest::Client::new();
265 let result = poll(&client, &server.uri(), "CID", "DC", 0, 60, no_sleep).await;
266 assert!(matches!(result, Err(ClientError::DeviceCodeAccessDenied)));
267 }
268
269 #[tokio::test]
270 async fn poll_returns_timeout_on_expired_token() {
271 let server = MockServer::start().await;
272 Mock::given(method("POST"))
273 .and(path("/oauth2/v2.0/token"))
274 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
275 "error": "expired_token"
276 })))
277 .mount(&server)
278 .await;
279
280 let client = reqwest::Client::new();
281 let result = poll(&client, &server.uri(), "CID", "DC", 0, 60, no_sleep).await;
282 assert!(matches!(result, Err(ClientError::DeviceCodeTimeout)));
283 }
284
285 #[tokio::test]
286 async fn poll_returns_other_on_unknown_error_code() {
287 let server = MockServer::start().await;
288 Mock::given(method("POST"))
289 .and(path("/oauth2/v2.0/token"))
290 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
291 "error": "consent_required",
292 "error_description": "AADSTS65001: consent needed"
293 })))
294 .mount(&server)
295 .await;
296
297 let client = reqwest::Client::new();
298 let result = poll(&client, &server.uri(), "CID", "DC", 0, 60, no_sleep).await;
299 match result {
300 Err(ClientError::DeviceCodeOther { kind, description }) => {
301 assert_eq!(kind, "consent_required");
302 assert_eq!(description.as_deref(), Some("AADSTS65001: consent needed"));
303 }
304 other => panic!("expected DeviceCodeOther, got {other:?}"),
305 }
306 }
307
308 #[tokio::test]
309 async fn poll_slow_down_increases_interval_then_succeeds() {
310 let server = MockServer::start().await;
311
312 Mock::given(method("POST"))
313 .and(path("/oauth2/v2.0/token"))
314 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
315 "error": "slow_down"
316 })))
317 .up_to_n_times(1)
318 .mount(&server)
319 .await;
320
321 Mock::given(method("POST"))
322 .and(path("/oauth2/v2.0/token"))
323 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
324 "access_token": "AT",
325 "refresh_token": "RT",
326 "expires_in": 3600
327 })))
328 .mount(&server)
329 .await;
330
331 let client = reqwest::Client::new();
332 let result = poll(&client, &server.uri(), "CID", "DC", 0, 60, no_sleep)
333 .await
334 .unwrap();
335 assert_eq!(result.tokens.access_token, "AT");
336 }
337}