Skip to main content

auths_cli/services/
oauth.rs

1use std::time::Duration;
2
3use anyhow::{Context, Result, bail};
4use serde::Deserialize;
5
6const DEFAULT_GITHUB_CLIENT_ID: &str = "Ov23lio2CiTHBjM2uIL4";
7
8fn github_client_id() -> String {
9    std::env::var("AUTHS_GITHUB_CLIENT_ID").unwrap_or_else(|_| DEFAULT_GITHUB_CLIENT_ID.to_string())
10}
11
12#[derive(Debug, Deserialize)]
13struct DeviceCodeResponse {
14    device_code: String,
15    user_code: String,
16    verification_uri: String,
17    expires_in: u64,
18    interval: u64,
19}
20
21#[derive(Debug, Deserialize)]
22struct TokenPollResponse {
23    access_token: Option<String>,
24    error: Option<String>,
25}
26
27#[derive(Debug, Deserialize)]
28struct GitHubUser {
29    login: String,
30}
31
32pub struct GithubAuth {
33    pub access_token: String,
34    pub username: String,
35}
36
37/// Runs the GitHub Device Flow (RFC 8628) to authenticate the user.
38///
39/// Args:
40/// * `client`: Pre-configured HTTP client from the composition root.
41/// * `out`: Output helper for progress messages.
42///
43/// Usage:
44/// ```ignore
45/// let auth = github_device_flow(&client, &out).await?;
46/// println!("Authenticated as {}", auth.username);
47/// ```
48pub async fn github_device_flow(
49    client: &reqwest::Client,
50    out: &crate::ux::format::Output,
51) -> Result<GithubAuth> {
52    let client_id = github_client_id();
53
54    // Step 1: Request device code
55    let device_resp: DeviceCodeResponse = client
56        .post("https://github.com/login/device/code")
57        .header("Accept", "application/json")
58        .form(&[
59            ("client_id", client_id.as_str()),
60            ("scope", "gist read:user"),
61        ])
62        .send()
63        .await
64        .context("Failed to request device code from GitHub")?
65        .json()
66        .await
67        .context("Failed to parse device code response")?;
68
69    // Step 2: Display user code and open browser
70    out.newline();
71    out.print_heading("GitHub Verification");
72    out.println(&format!(
73        "  Enter this code: {}",
74        out.bold(&device_resp.user_code)
75    ));
76    out.println(&format!(
77        "  At: {}",
78        out.info(&device_resp.verification_uri)
79    ));
80    out.newline();
81
82    // Best-effort browser open
83    if let Err(e) = open::that(&device_resp.verification_uri) {
84        out.print_warn(&format!("Could not open browser automatically: {e}"));
85        out.println("  Please open the URL above manually.");
86    } else {
87        out.println("  Browser opened — waiting for authorization...");
88    }
89
90    // Step 3: Poll for token
91    let mut interval = Duration::from_secs(device_resp.interval.max(5));
92    let deadline = tokio::time::Instant::now() + Duration::from_secs(device_resp.expires_in);
93
94    let access_token = loop {
95        tokio::time::sleep(interval).await;
96
97        if tokio::time::Instant::now() > deadline {
98            bail!("GitHub authorization timed out. Run `auths init` to try again.");
99        }
100
101        let poll_resp: TokenPollResponse = client
102            .post("https://github.com/login/oauth/access_token")
103            .header("Accept", "application/json")
104            .form(&[
105                ("client_id", client_id.as_str()),
106                ("device_code", device_resp.device_code.as_str()),
107                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
108            ])
109            .send()
110            .await
111            .context("Failed to poll GitHub for access token")?
112            .json()
113            .await
114            .context("Failed to parse token poll response")?;
115
116        match poll_resp.error.as_deref() {
117            Some("authorization_pending") => continue,
118            Some("slow_down") => {
119                interval += Duration::from_secs(5);
120                continue;
121            }
122            Some("expired_token") => {
123                bail!("GitHub authorization expired. Run `auths init` to try again.");
124            }
125            Some("access_denied") => {
126                bail!("GitHub authorization was denied by the user.");
127            }
128            Some(other) => {
129                bail!("GitHub OAuth error: {other}");
130            }
131            None => {}
132        }
133
134        if let Some(token) = poll_resp.access_token {
135            break token;
136        }
137    };
138
139    // Step 4: Fetch username
140    let user: GitHubUser = client
141        .get("https://api.github.com/user")
142        .header("Authorization", format!("Bearer {access_token}"))
143        .header("User-Agent", "auths-cli")
144        .send()
145        .await
146        .context("Failed to fetch GitHub user profile")?
147        .json()
148        .await
149        .context("Failed to parse GitHub user response")?;
150
151    out.print_success(&format!("Authenticated as @{}", user.login));
152
153    Ok(GithubAuth {
154        access_token,
155        username: user.login,
156    })
157}