speccoder_runtime/github/
auth.rs1use std::fmt;
8
9pub const SERVICE: &str = "speccoder";
11
12pub const SCOPES: &str = "repo read:org";
17
18#[derive(Clone, PartialEq, Eq)]
20pub struct Secret(String);
21
22impl Secret {
23 #[must_use]
25 pub fn new(s: impl Into<String>) -> Self {
26 Self(s.into().trim().to_owned())
27 }
28
29 #[must_use]
32 pub fn expose(&self) -> &str {
33 &self.0
34 }
35}
36
37impl fmt::Debug for Secret {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str("Secret([redacted])")
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Source {
46 Env(&'static str),
48 Keychain,
50}
51
52impl Source {
53 #[must_use]
55 pub fn word(self) -> &'static str {
56 match self {
57 Self::Env(v) => v,
58 Self::Keychain => "the keychain",
59 }
60 }
61}
62
63fn account(host: &str) -> String {
64 format!("github:{host}")
65}
66
67fn entry(host: &str) -> Result<keyring::Entry, String> {
68 keyring::Entry::new(SERVICE, &account(host)).map_err(|e| store_error(&e))
69}
70
71fn store_error(e: &keyring::Error) -> String {
72 format!(
73 "the credential store is not available ({e}); set GH_TOKEN instead — speccoder keeps no token in a file"
74 )
75}
76
77#[must_use]
79pub fn token(host: &str) -> Option<(Secret, Source)> {
80 let vars: &[&'static str] = if host == "github.com" {
81 &["GH_TOKEN", "GITHUB_TOKEN"]
82 } else {
83 &["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]
84 };
85 for v in vars {
86 if let Some(t) = std::env::var(v).ok().filter(|t| !t.trim().is_empty()) {
87 return Some((Secret::new(t), Source::Env(v)));
88 }
89 }
90 if std::env::var_os("SPECCODER_NO_KEYCHAIN").is_some() {
92 return None;
93 }
94 entry(host)
95 .ok()?
96 .get_password()
97 .ok()
98 .filter(|t| !t.trim().is_empty())
99 .map(|t| (Secret::new(t), Source::Keychain))
100}
101
102pub fn store(host: &str, token: &Secret) -> Result<(), String> {
108 entry(host)?
109 .set_password(token.expose())
110 .map_err(|e| store_error(&e))
111}
112
113pub fn forget(host: &str) -> Result<bool, String> {
119 match entry(host)?.delete_credential() {
120 Ok(()) => Ok(true),
121 Err(keyring::Error::NoEntry) => Ok(false),
122 Err(e) => Err(store_error(&e)),
123 }
124}
125
126pub const APP_CLIENT_ID: &str = "Iv23liZofBDOUS5F5z2g";
131
132pub const APP_SLUG: &str = "speccoder-app";
134
135#[must_use]
137pub fn install_url() -> String {
138 format!("https://github.com/apps/{APP_SLUG}/installations/new")
139}
140
141#[must_use]
145pub fn client_id(host: &str) -> Option<String> {
146 std::env::var("SPECCODER_GITHUB_CLIENT_ID")
147 .ok()
148 .filter(|c| !c.trim().is_empty())
149 .or_else(|| (host == "github.com").then(|| APP_CLIENT_ID.to_owned()))
150}
151
152#[derive(Debug, Clone)]
154pub struct Pending {
155 pub user_code: String,
157 pub verification_uri: String,
159 device_code: Secret,
160 interval: u64,
161 expires_in: u64,
162}
163
164pub fn start(oauth: &str, client_id: &str) -> Result<Pending, String> {
170 let v = super::http::form(
171 &format!("{oauth}/login/device/code"),
172 &[("client_id", client_id), ("scope", SCOPES)],
173 )?;
174 let s = |k: &str| v[k].as_str().unwrap_or_default().to_owned();
175 if s("device_code").is_empty() {
176 return Err(format!("the device flow did not start: {v}"));
177 }
178 Ok(Pending {
179 user_code: s("user_code"),
180 verification_uri: s("verification_uri"),
181 device_code: Secret::new(s("device_code")),
182 interval: v["interval"].as_u64().unwrap_or(5).max(1),
183 expires_in: v["expires_in"].as_u64().unwrap_or(900),
184 })
185}
186
187pub fn wait(oauth: &str, client_id: &str, p: &Pending) -> Result<Secret, String> {
193 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(p.expires_in);
194 let mut interval = p.interval;
195 while std::time::Instant::now() < deadline {
196 std::thread::sleep(std::time::Duration::from_secs(interval));
197 let v = super::http::form(
198 &format!("{oauth}/login/oauth/access_token"),
199 &[
200 ("client_id", client_id),
201 ("device_code", p.device_code.expose()),
202 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
203 ],
204 )?;
205 if let Some(t) = v["access_token"].as_str().filter(|t| !t.is_empty()) {
206 return Ok(Secret::new(t));
207 }
208 match v["error"].as_str().unwrap_or_default() {
209 "authorization_pending" => {}
210 "slow_down" => interval += 5,
211 "expired_token" => break,
212 "access_denied" => return Err("the sign-in was denied".to_owned()),
213 other => return Err(format!("the sign-in failed: {other}")),
214 }
215 }
216 Err("the code expired before it was entered; sign in again".to_owned())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn a_token_never_prints() {
225 let s = Secret::new(" ghp_secret \n");
226 assert_eq!(s.expose(), "ghp_secret");
227 assert_eq!(format!("{s:?}"), "Secret([redacted])");
228 }
229}