auths_cli/services/
oauth.rs1use 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
37pub 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 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 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 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 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 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}