Skip to main content

josh_github_auth/
device_flow.rs

1use anyhow::{Result, anyhow};
2use reqwest::header;
3use serde::Deserialize;
4use std::time::Duration;
5use tokio::sync::mpsc;
6
7const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
8const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
9
10/// Step 1 response: codes for the user to enter.
11#[derive(Debug, Deserialize)]
12pub struct DeviceCodeResponse {
13    pub device_code: String,
14    pub user_code: String,
15    pub verification_uri: String,
16    pub verification_uri_complete: Option<String>,
17    pub expires_in: u64,
18    pub interval: u64,
19}
20
21/// Step 3 response: the access token.
22#[derive(Debug, Deserialize)]
23pub struct AccessTokenResponse {
24    pub access_token: String,
25    pub token_type: String,
26    pub scope: String,
27    pub refresh_token: Option<String>,
28    pub expires_in: Option<u64>,
29}
30
31/// Refreshed token response.
32#[derive(Debug, Deserialize)]
33pub struct RefreshTokenResponse {
34    pub access_token: String,
35    pub token_type: String,
36    pub scope: String,
37    pub refresh_token: String,
38    pub expires_in: u64,
39}
40
41#[derive(Debug, Deserialize)]
42#[serde(rename_all = "snake_case")]
43enum DeviceFlowError {
44    AuthorizationPending,
45    SlowDown,
46    ExpiredToken,
47    AccessDenied,
48    #[serde(other)]
49    Unknown,
50}
51
52#[derive(Debug, Deserialize)]
53struct ErrorResponse {
54    error: DeviceFlowError,
55    #[allow(dead_code)]
56    error_description: Option<String>,
57}
58
59pub struct DeviceAuthFlow {
60    client: reqwest::Client,
61    client_id: String,
62}
63
64impl DeviceAuthFlow {
65    pub fn new(client_id: impl AsRef<str>) -> Self {
66        Self {
67            client: reqwest::Client::new(),
68            client_id: client_id.as_ref().into(),
69        }
70    }
71
72    async fn send_form(&self, url: &str, params: &[(&str, &str)]) -> Result<String> {
73        let body = form_urlencoded::Serializer::new(String::new())
74            .extend_pairs(params)
75            .finish();
76
77        let resp = self
78            .client
79            .post(url)
80            .header(header::ACCEPT, mime::APPLICATION_JSON.as_ref())
81            .header(
82                header::CONTENT_TYPE,
83                mime::APPLICATION_WWW_FORM_URLENCODED.as_ref(),
84            )
85            .body(body)
86            .send()
87            .await?;
88
89        let status = resp.status();
90        let body = resp.text().await?;
91
92        if !status.is_success() {
93            return Err(anyhow!("request to {} failed ({}): {}", url, status, body));
94        }
95
96        Ok(body)
97    }
98
99    /// Step 1: Request device and user codes from GitHub.
100    pub async fn request_device_code(&self) -> Result<DeviceCodeResponse> {
101        let body = self
102            .send_form(
103                GITHUB_DEVICE_CODE_URL,
104                &[
105                    ("client_id", &self.client_id),
106                    ("scope", &"repo workflow".to_string()),
107                ],
108            )
109            .await?;
110
111        Ok(serde_json::from_str(&body)?)
112    }
113
114    /// Step 2+3: Poll GitHub until the user authorizes the device.
115    ///
116    /// This blocks (async sleep) respecting the `interval` from the device code
117    /// response. Backs off on `slow_down` and stops on `expired_token` or
118    /// `access_denied`.
119    ///
120    /// An optional `notify` receiver can be passed to signal the poll loop to
121    /// check immediately instead of waiting for the full interval to elapse.
122    pub async fn poll_for_token(
123        &self,
124        device_code: &DeviceCodeResponse,
125        mut notify: Option<mpsc::UnboundedReceiver<()>>,
126    ) -> Result<AccessTokenResponse> {
127        let mut interval = Duration::from_secs(device_code.interval);
128
129        loop {
130            match &mut notify {
131                Some(rx) => {
132                    tokio::select! {
133                        _ = tokio::time::sleep(interval) => {}
134                        _ = rx.recv() => {}
135                    }
136                }
137                None => {
138                    tokio::time::sleep(interval).await;
139                }
140            }
141
142            let body = self
143                .send_form(
144                    GITHUB_ACCESS_TOKEN_URL,
145                    &[
146                        ("client_id", self.client_id.as_str()),
147                        ("device_code", &device_code.device_code),
148                        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
149                    ],
150                )
151                .await?;
152
153            // GitHub returns 200 even for pending/error states — check for
154            // the `error` field to distinguish.
155            if let Ok(err) = serde_json::from_str::<ErrorResponse>(&body) {
156                match err.error {
157                    DeviceFlowError::AuthorizationPending => {
158                        tracing::debug!("authorization pending, polling again");
159                        continue;
160                    }
161                    DeviceFlowError::SlowDown => {
162                        interval += Duration::from_secs(5);
163                        tracing::debug!("slow_down received, interval now {:?}", interval);
164                        continue;
165                    }
166                    DeviceFlowError::ExpiredToken => {
167                        return Err(anyhow!("device code expired, please restart the flow"));
168                    }
169                    DeviceFlowError::AccessDenied => {
170                        return Err(anyhow!("user denied authorization"));
171                    }
172                    DeviceFlowError::Unknown => {
173                        return Err(anyhow!("unexpected error from GitHub: {}", body));
174                    }
175                }
176            }
177
178            return Ok(serde_json::from_str(&body)?);
179        }
180    }
181
182    /// Refresh an expired access token using a refresh token.
183    pub async fn refresh_token(&self, refresh_token: &str) -> Result<RefreshTokenResponse> {
184        let body = self
185            .send_form(
186                GITHUB_ACCESS_TOKEN_URL,
187                &[
188                    ("client_id", self.client_id.as_str()),
189                    ("grant_type", "refresh_token"),
190                    ("refresh_token", refresh_token),
191                ],
192            )
193            .await?;
194
195        Ok(serde_json::from_str(&body)?)
196    }
197}