Skip to main content

speccoder_runtime/github/
auth.rs

1//! Who speccoder is on GitHub: a token from the environment (CI:
2//! `GH_TOKEN`, `GITHUB_TOKEN`; an Enterprise host: `GH_ENTERPRISE_TOKEN`),
3//! else the one `speccoder auth login` kept in the operating system's
4//! credential store (service `speccoder`, account `github:<host>`). Never a
5//! file; never printed.
6
7use std::fmt;
8
9/// The credential-store service every token is filed under.
10pub const SERVICE: &str = "speccoder";
11
12/// What a token needs: `repo` to read issues and open pull requests on a
13/// private repository, `read:org` to see team review requests. A
14/// fine-grained token needs Issues (read), Pull requests (read and write),
15/// Contents and Metadata (read).
16pub const SCOPES: &str = "repo read:org";
17
18/// A token: redacted wherever it is printed.
19#[derive(Clone, PartialEq, Eq)]
20pub struct Secret(String);
21
22impl Secret {
23    /// Wraps a token.
24    #[must_use]
25    pub fn new(s: impl Into<String>) -> Self {
26        Self(s.into().trim().to_owned())
27    }
28
29    /// The value, for the one place it goes: a header to its own host, or
30    /// the credential store.
31    #[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/// Where a token came from.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Source {
46    /// An environment variable (its name).
47    Env(&'static str),
48    /// The operating system's credential store.
49    Keychain,
50}
51
52impl Source {
53    /// `GH_TOKEN`, `the keychain`.
54    #[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/// The token for `host`, and where it came from.
78#[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    // A machine without a person never reaches the store.
91    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
102/// Keeps `token` for `host` in the credential store.
103///
104/// # Errors
105///
106/// No credential store here.
107pub 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
113/// Forgets `host`'s token; `Ok(false)`: there was none.
114///
115/// # Errors
116///
117/// No credential store here.
118pub 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
126/// speccoder's GitHub App: its Client ID (public by design — it
127/// names the app, it proves nothing) and its slug. Repository permissions:
128/// Contents, Checks, Commit statuses, Metadata read; Issues and Pull requests
129/// read and write; user tokens that do not expire; the device flow.
130pub const APP_CLIENT_ID: &str = "Iv23liZofBDOUS5F5z2g";
131
132/// `https://github.com/apps/speccoder-app`.
133pub const APP_SLUG: &str = "speccoder-app";
134
135/// Where a repository's owner installs speccoder's app.
136#[must_use]
137pub fn install_url() -> String {
138    format!("https://github.com/apps/{APP_SLUG}/installations/new")
139}
140
141/// The app the device flow signs in with on `host`:
142/// `SPECCODER_GITHUB_CLIENT_ID` (an Enterprise server's own app, or another
143/// one), else speccoder's on github.com; none on another host.
144#[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/// A device-flow sign-in waiting for the person: the code to type, where.
153#[derive(Debug, Clone)]
154pub struct Pending {
155    /// What the person types at `verification_uri`.
156    pub user_code: String,
157    /// `https://github.com/login/device`.
158    pub verification_uri: String,
159    device_code: Secret,
160    interval: u64,
161    expires_in: u64,
162}
163
164/// Starts the device flow on `oauth` (`https://github.com`).
165///
166/// # Errors
167///
168/// No client id, or the host refused.
169pub 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
187/// Waits until the person entered the code (or it expired): the token.
188///
189/// # Errors
190///
191/// Expired, denied, or the host refused.
192pub 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}