Skip to main content

nomoreide_core/
github_oauth.rs

1//! GitHub's OAuth device flow: the two calls that turn a code the user types
2//! into a token this machine can store.
3//!
4//! Only the transport lives here. What each answer *means* — which shape is a
5//! success, which is "still waiting", which is a refusal — is decided by the
6//! caller, because that decision is the same one the reference makes in its
7//! route rather than in a client.
8
9use serde_json::{json, Value};
10
11const GITHUB_WEB: &str = "https://github.com";
12const DEVICE_CODE_PATH: &str = "/login/device/code";
13const ACCESS_TOKEN_PATH: &str = "/login/oauth/access_token";
14const SCOPES: &str = "repo workflow read:org";
15
16/// The app the device flow authorizes.
17///
18/// Shipped with a default rather than demanded from the environment: the
19/// client id of a device-flow app is public by design — it identifies the app
20/// to GitHub and authorizes nothing on its own — so an install that sets
21/// nothing still gets a working "Connect GitHub" button.
22const DEFAULT_CLIENT_ID: &str = "Ov23litfv3LE0LevxlT2";
23
24/// Which app to authorize as. An override that trims away to nothing is not an
25/// override, so a variable set to blank behaves as if it were unset.
26pub fn client_id() -> String {
27    std::env::var("NOMOREIDE_GITHUB_CLIENT_ID")
28        .ok()
29        .map(|value| value.trim().to_string())
30        .filter(|value| !value.is_empty())
31        .unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string())
32}
33
34/// Where the device flow's endpoints live: `github.com`, unless
35/// `NOMOREIDE_GITHUB_OAUTH_BASE` names a loopback address.
36///
37/// The same rule [`crate::github_manager::api_base`] follows, and for the same
38/// reason: the second of these two calls *returns a token*, so an override
39/// free to name any host would be a way to feed this machine someone else's
40/// credential — or to collect the one it asked for.
41pub fn oauth_base() -> String {
42    crate::github_manager::loopback_override("NOMOREIDE_GITHUB_OAUTH_BASE", GITHUB_WEB)
43}
44
45/// Ask GitHub for a device code and the URL to type it into.
46pub async fn request_device_code(client_id: &str) -> Result<Value, String> {
47    post(
48        DEVICE_CODE_PATH,
49        json!({ "client_id": client_id, "scope": SCOPES }),
50    )
51    .await
52}
53
54/// Ask whether the user has finished authorizing yet.
55pub async fn request_access_token(client_id: &str, device_code: &str) -> Result<Value, String> {
56    post(
57        ACCESS_TOKEN_PATH,
58        json!({
59            "client_id": client_id,
60            "device_code": device_code,
61            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
62        }),
63    )
64    .await
65}
66
67/// One device-flow call.
68///
69/// The status is deliberately not checked. GitHub answers `authorization_
70/// pending` with a 200 and some of its refusals with a 4xx, and the caller
71/// tells those apart by the `error` field either way — so a body that parses
72/// is handed over whatever the status line said, exactly as the reference's
73/// unconditional `res.json()` does.
74async fn post(path: &str, body: Value) -> Result<Value, String> {
75    let response = reqwest::Client::new()
76        .post(format!("{}{path}", oauth_base()))
77        .header("Accept", "application/json")
78        .header("Content-Type", "application/json")
79        .json(&body)
80        .send()
81        .await
82        .map_err(|error| error.to_string())?;
83    let text = response.text().await.map_err(|error| error.to_string())?;
84    serde_json::from_str(&text).map_err(|error| error.to_string())
85}