Skip to main content

memstead_cli/auth/
device_flow.rs

1//! GitHub OAuth Device Flow client.
2//!
3//! Reference: <https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow>
4//!
5//! Three steps:
6//!
7//! 1. `POST https://github.com/login/device/code` → returns a user
8//!    code + device code + verification URI + polling interval.
9//! 2. Print the user code, open the verification URI in the browser,
10//!    wait for the user to approve.
11//! 3. Poll `POST https://github.com/login/oauth/access_token` at the
12//!    advertised interval until the server either returns an access
13//!    token, a terminal error (`access_denied`, `expired_token`), or
14//!    we exceed the code expiry.
15//!
16//! Device Flow is a public-client protocol — no client secret exists.
17//! The client ID is the one registered for the registry's GitHub OAuth
18//! App.
19
20use std::io::Write;
21use std::time::{Duration, Instant};
22
23use anyhow::{Context, Result};
24use serde::{Deserialize, Serialize};
25
26/// GitHub OAuth App client ID for memstead.io. Public; safe to commit.
27pub const MEMSTEAD_GITHUB_CLIENT_ID: &str = "Ov23linvCi8kvFipqMHh";
28
29/// Scope requested at authorization time. `read:user` is the minimum
30/// the registry needs to resolve the username; asking for more would
31/// scare users off in the GitHub approval screen.
32pub const MEMSTEAD_GITHUB_SCOPE: &str = "read:user";
33
34/// Where `GET /user` lives in `crate::auth::device_flow` — used by
35/// tests that stand up a GitHub mock so the device flow doesn't hit
36/// real github.com.
37const 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    /// Seconds until `device_code` expires.
55    pub expires_in: u64,
56    /// Minimum seconds between polling attempts.
57    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
86/// Result of a successful flow.
87pub struct DeviceFlowOutcome {
88    pub access_token: String,
89    pub scopes: Vec<String>,
90}
91
92/// Run the device flow end-to-end against the host in
93/// `MEMSTEAD_GITHUB_HOST` (default `https://github.com`). Stdout receives
94/// the user-facing prompt; stderr is left alone so CI logs stay clean.
95///
96/// The `on_open` closure is called once with the verification URI so
97/// the caller can decide whether to try opening a browser (interactive
98/// publish) or stay text-only (CI preflight). It receives the URI by
99/// reference and returns nothing — errors are ignored (the printed
100/// URL + user code remain actionable).
101pub 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            // GitHub normally returns 200 with an error body; a 4xx/5xx
183            // here is a real protocol failure, not a pending approval.
184            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                // Still waiting — expected.
211                "authorization_pending" => {}
212                // Polled too fast; honour the slowdown hint.
213                "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
232/// Best-effort browser open. Returns whether the open-command launched
233/// without immediate failure. Never panics; the printed URL + code
234/// remain actionable if this fails.
235pub 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}