josh_github_auth/
device_flow.rs1use 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#[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#[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#[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 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 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 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 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}