Skip to main content

pidge_client/auth/
device_code.rs

1//! RFC 8628 OAuth 2.0 device authorization grant flow.
2
3use std::time::Duration;
4
5use chrono::Utc;
6use serde::Deserialize;
7
8use crate::auth::tokens::TokenSet;
9use crate::error::ClientError;
10
11/// Response from the devicecode endpoint.
12#[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/// Token response. `refresh_token` is optional because refresh-token-rotation
24/// responses sometimes omit it; for device-code initial responses Microsoft always
25/// includes it (the `offline_access` scope is requested).
26#[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    /// The scopes granted, space-separated.
36    #[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/// Result of a successful poll: tokens + the id_token (for tenant_id extraction).
48#[derive(Debug)]
49pub struct PollSuccess {
50    pub tokens: TokenSet,
51    pub id_token: Option<String>,
52}
53
54/// POST to `{base_url}/oauth2/v2.0/devicecode`.
55pub 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
80/// Poll `{base_url}/oauth2/v2.0/token` per RFC 8628 ยง3.5.
81/// Returns when the user has approved consent, or with an error on denial / expiry.
82///
83/// The `sleep` parameter is a closure so tests can inject a fast no-op sleep
84/// instead of real `tokio::time::sleep`.
85pub 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
159/// Real-time sleep helper for production use.
160pub 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    /// Test sleep: no-op; we don't actually want to wait in tests.
171    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}