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}