use std::io::Write;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const MEMSTEAD_GITHUB_CLIENT_ID: &str = "Ov23linvCi8kvFipqMHh";
pub const MEMSTEAD_GITHUB_SCOPE: &str = "read:user";
const GITHUB_HOST_DEFAULT: &str = "https://github.com";
fn github_host() -> String {
std::env::var("MEMSTEAD_GITHUB_HOST").unwrap_or_else(|_| GITHUB_HOST_DEFAULT.to_string())
}
#[derive(Debug, Clone, Serialize)]
struct DeviceCodeRequest<'a> {
client_id: &'a str,
scope: &'a str,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, Serialize)]
struct TokenRequest<'a> {
client_id: &'a str,
device_code: &'a str,
grant_type: &'static str,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum TokenResponse {
Success {
access_token: String,
#[serde(default)]
scope: String,
#[serde(default)]
#[allow(dead_code)]
token_type: String,
},
Error {
error: String,
#[serde(default)]
#[allow(dead_code)]
error_description: String,
},
}
pub struct DeviceFlowOutcome {
pub access_token: String,
pub scopes: Vec<String>,
}
pub fn run(
client: &reqwest::blocking::Client,
client_id: &str,
scope: &str,
on_open: impl FnOnce(&str),
) -> Result<DeviceFlowOutcome> {
let base = github_host();
let code = request_device_code(client, &base, client_id, scope)?;
println!();
println!("To authorize memstead, open");
println!(" {}", code.verification_uri);
println!("and enter the code");
println!();
println!(" {}", code.user_code);
println!();
println!("Waiting for authorization (Ctrl-C to abort)…");
std::io::stdout().flush().ok();
on_open(&code.verification_uri);
poll_for_token(client, &base, client_id, &code)
}
fn request_device_code(
client: &reqwest::blocking::Client,
base: &str,
client_id: &str,
scope: &str,
) -> Result<DeviceCodeResponse> {
let url = format!("{}/login/device/code", base.trim_end_matches('/'));
let resp = client
.post(url)
.header("accept", "application/json")
.form(&DeviceCodeRequest { client_id, scope })
.send()
.context("requesting device code from GitHub")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().unwrap_or_default();
anyhow::bail!(
"GitHub rejected the device-code request ({status}): {}",
body.chars().take(200).collect::<String>()
);
}
resp.json::<DeviceCodeResponse>()
.context("parsing device-code response")
}
fn poll_for_token(
client: &reqwest::blocking::Client,
base: &str,
client_id: &str,
code: &DeviceCodeResponse,
) -> Result<DeviceFlowOutcome> {
let url = format!("{}/login/oauth/access_token", base.trim_end_matches('/'));
let deadline = Instant::now() + Duration::from_secs(code.expires_in);
let mut interval = Duration::from_secs(code.interval.max(1));
loop {
if Instant::now() >= deadline {
anyhow::bail!(
"device code expired before approval — rerun `memstead login` or `memstead publish`"
);
}
std::thread::sleep(interval);
let body = TokenRequest {
client_id,
device_code: &code.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
};
let resp = client
.post(&url)
.header("accept", "application/json")
.form(&body)
.send()
.context("polling GitHub for access token")?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
anyhow::bail!(
"GitHub returned {status} while polling for token: {}",
text.chars().take(200).collect::<String>()
);
}
let parsed: TokenResponse = resp.json().context("parsing token response")?;
match parsed {
TokenResponse::Success {
access_token,
scope,
..
} => {
let scopes: Vec<String> = scope
.split([',', ' '])
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
return Ok(DeviceFlowOutcome {
access_token,
scopes,
});
}
TokenResponse::Error { error, .. } => match error.as_str() {
"authorization_pending" => {}
"slow_down" => {
interval += Duration::from_secs(5);
}
"expired_token" => {
anyhow::bail!("device code expired before approval — rerun `memstead login`")
}
"access_denied" => {
anyhow::bail!("authorization was denied on GitHub")
}
"unsupported_grant_type" => anyhow::bail!(
"GitHub rejected the device-flow grant — the OAuth App may \
not have Device Flow enabled"
),
other => anyhow::bail!("unexpected device-flow error from GitHub: {other}"),
},
}
}
}
pub fn open_browser(url: &str) -> bool {
#[cfg(target_os = "macos")]
let launcher = ("open", vec![url]);
#[cfg(target_os = "linux")]
let launcher = ("xdg-open", vec![url]);
#[cfg(target_os = "windows")]
let launcher = ("cmd", vec!["/C", "start", "", url]);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let launcher: (&str, Vec<&str>) = ("", vec![]);
if launcher.0.is_empty() {
return false;
}
std::process::Command::new(launcher.0)
.args(&launcher.1)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}