memstead_cli/auth/
device_flow.rs1use std::io::Write;
21use std::time::{Duration, Instant};
22
23use anyhow::{Context, Result};
24use serde::{Deserialize, Serialize};
25
26pub const MEMSTEAD_GITHUB_CLIENT_ID: &str = "Ov23linvCi8kvFipqMHh";
28
29pub const MEMSTEAD_GITHUB_SCOPE: &str = "read:user";
33
34const GITHUB_HOST_DEFAULT: &str = "https://github.com";
38
39fn github_host() -> String {
40 std::env::var("MEMSTEAD_GITHUB_HOST").unwrap_or_else(|_| GITHUB_HOST_DEFAULT.to_string())
41}
42
43#[derive(Debug, Clone, Serialize)]
44struct DeviceCodeRequest<'a> {
45 client_id: &'a str,
46 scope: &'a str,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct DeviceCodeResponse {
51 pub device_code: String,
52 pub user_code: String,
53 pub verification_uri: String,
54 pub expires_in: u64,
56 pub interval: u64,
58}
59
60#[derive(Debug, Clone, Serialize)]
61struct TokenRequest<'a> {
62 client_id: &'a str,
63 device_code: &'a str,
64 grant_type: &'static str,
65}
66
67#[derive(Debug, Clone, Deserialize)]
68#[serde(untagged)]
69enum TokenResponse {
70 Success {
71 access_token: String,
72 #[serde(default)]
73 scope: String,
74 #[serde(default)]
75 #[allow(dead_code)]
76 token_type: String,
77 },
78 Error {
79 error: String,
80 #[serde(default)]
81 #[allow(dead_code)]
82 error_description: String,
83 },
84}
85
86pub struct DeviceFlowOutcome {
88 pub access_token: String,
89 pub scopes: Vec<String>,
90}
91
92pub fn run(
102 client: &reqwest::blocking::Client,
103 client_id: &str,
104 scope: &str,
105 on_open: impl FnOnce(&str),
106) -> Result<DeviceFlowOutcome> {
107 let base = github_host();
108 let code = request_device_code(client, &base, client_id, scope)?;
109
110 println!();
111 println!("To authorize memstead, open");
112 println!(" {}", code.verification_uri);
113 println!("and enter the code");
114 println!();
115 println!(" {}", code.user_code);
116 println!();
117 println!("Waiting for authorization (Ctrl-C to abort)…");
118 std::io::stdout().flush().ok();
119
120 on_open(&code.verification_uri);
121
122 poll_for_token(client, &base, client_id, &code)
123}
124
125fn request_device_code(
126 client: &reqwest::blocking::Client,
127 base: &str,
128 client_id: &str,
129 scope: &str,
130) -> Result<DeviceCodeResponse> {
131 let url = format!("{}/login/device/code", base.trim_end_matches('/'));
132 let resp = client
133 .post(url)
134 .header("accept", "application/json")
135 .form(&DeviceCodeRequest { client_id, scope })
136 .send()
137 .context("requesting device code from GitHub")?;
138 if !resp.status().is_success() {
139 let status = resp.status();
140 let body = resp.text().unwrap_or_default();
141 anyhow::bail!(
142 "GitHub rejected the device-code request ({status}): {}",
143 body.chars().take(200).collect::<String>()
144 );
145 }
146 resp.json::<DeviceCodeResponse>()
147 .context("parsing device-code response")
148}
149
150fn poll_for_token(
151 client: &reqwest::blocking::Client,
152 base: &str,
153 client_id: &str,
154 code: &DeviceCodeResponse,
155) -> Result<DeviceFlowOutcome> {
156 let url = format!("{}/login/oauth/access_token", base.trim_end_matches('/'));
157 let deadline = Instant::now() + Duration::from_secs(code.expires_in);
158 let mut interval = Duration::from_secs(code.interval.max(1));
159
160 loop {
161 if Instant::now() >= deadline {
162 anyhow::bail!(
163 "device code expired before approval — rerun `memstead login` or `memstead publish`"
164 );
165 }
166 std::thread::sleep(interval);
167
168 let body = TokenRequest {
169 client_id,
170 device_code: &code.device_code,
171 grant_type: "urn:ietf:params:oauth:grant-type:device_code",
172 };
173
174 let resp = client
175 .post(&url)
176 .header("accept", "application/json")
177 .form(&body)
178 .send()
179 .context("polling GitHub for access token")?;
180
181 if !resp.status().is_success() {
182 let status = resp.status();
185 let text = resp.text().unwrap_or_default();
186 anyhow::bail!(
187 "GitHub returned {status} while polling for token: {}",
188 text.chars().take(200).collect::<String>()
189 );
190 }
191
192 let parsed: TokenResponse = resp.json().context("parsing token response")?;
193 match parsed {
194 TokenResponse::Success {
195 access_token,
196 scope,
197 ..
198 } => {
199 let scopes: Vec<String> = scope
200 .split([',', ' '])
201 .filter(|s| !s.is_empty())
202 .map(str::to_string)
203 .collect();
204 return Ok(DeviceFlowOutcome {
205 access_token,
206 scopes,
207 });
208 }
209 TokenResponse::Error { error, .. } => match error.as_str() {
210 "authorization_pending" => {}
212 "slow_down" => {
214 interval += Duration::from_secs(5);
215 }
216 "expired_token" => {
217 anyhow::bail!("device code expired before approval — rerun `memstead login`")
218 }
219 "access_denied" => {
220 anyhow::bail!("authorization was denied on GitHub")
221 }
222 "unsupported_grant_type" => anyhow::bail!(
223 "GitHub rejected the device-flow grant — the OAuth App may \
224 not have Device Flow enabled"
225 ),
226 other => anyhow::bail!("unexpected device-flow error from GitHub: {other}"),
227 },
228 }
229 }
230}
231
232pub fn open_browser(url: &str) -> bool {
236 #[cfg(target_os = "macos")]
237 let launcher = ("open", vec![url]);
238 #[cfg(target_os = "linux")]
239 let launcher = ("xdg-open", vec![url]);
240 #[cfg(target_os = "windows")]
241 let launcher = ("cmd", vec!["/C", "start", "", url]);
242 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
243 let launcher: (&str, Vec<&str>) = ("", vec![]);
244
245 if launcher.0.is_empty() {
246 return false;
247 }
248
249 std::process::Command::new(launcher.0)
250 .args(&launcher.1)
251 .stdout(std::process::Stdio::null())
252 .stderr(std::process::Stdio::null())
253 .spawn()
254 .is_ok()
255}