ghtool/github/
auth_client.rs

1use eyre::Result;
2use http::HeaderMap;
3use serde::Deserialize;
4use tracing::{error, info};
5
6pub struct GithubAuthClient {
7    client: reqwest::Client,
8}
9
10const GITHUB_BASE_URI: &str = "https://github.com";
11const CLIENT_ID: &str = "32a2525cc736ee9b63ae";
12const USER_AGENT: &str = "ghtool";
13const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
14
15#[derive(Deserialize, Debug)]
16pub struct CodeResponse {
17    pub device_code: String,
18    pub user_code: String,
19    pub verification_uri: String,
20    pub expires_in: u32,
21    pub interval: u32,
22}
23
24#[derive(Deserialize, Debug)]
25pub struct AccessToken {
26    pub access_token: String,
27    pub scope: String,
28    pub token_type: String,
29}
30
31#[derive(Deserialize, Debug)]
32pub struct Error {
33    pub error: String,
34    pub error_description: String,
35    pub error_uri: String,
36}
37
38pub enum AccessTokenResponse {
39    AuthorizationPending(Error),
40    AccessToken(AccessToken),
41}
42
43impl GithubAuthClient {
44    pub fn new() -> Result<Self> {
45        let client = reqwest::Client::builder()
46            .user_agent(USER_AGENT)
47            .default_headers(make_headers())
48            .build()
49            .map_err(|e| eyre::eyre!("Failed to build client: {}", e))?;
50
51        Ok(Self { client })
52    }
53
54    pub async fn get_device_code(&self) -> Result<CodeResponse> {
55        let params = [("client_id", CLIENT_ID), ("scope", "repo")];
56        let url = format!("{}/login/device/code", GITHUB_BASE_URI);
57        info!("Requesting device code from {}", url);
58        let res = self.client.post(url).form(&params).send().await?;
59        let code_response: CodeResponse = res.json().await?;
60        info!("Received device code: {:?}", code_response);
61        Ok(code_response)
62    }
63
64    pub async fn get_access_token(&self, device_code: &str) -> Result<AccessTokenResponse> {
65        let params = [
66            ("client_id", CLIENT_ID),
67            ("device_code", device_code),
68            ("grant_type", GRANT_TYPE),
69        ];
70        let url = format!("{}/login/oauth/access_token", GITHUB_BASE_URI);
71        info!("Requesting access token from {}", url);
72        let res = self.client.post(url).form(&params).send().await?;
73
74        if res.status().is_success() {
75            let bytes = res.bytes().await?;
76            let token_response: Result<AccessToken, _> = serde_json::from_slice(&bytes);
77            info!("Received response: {:?}", token_response);
78            match token_response {
79                Ok(token) => Ok(AccessTokenResponse::AccessToken(token)),
80                Err(_) => {
81                    let error_response: Error = serde_json::from_slice(&bytes)?;
82                    if error_response.error == "authorization_pending" {
83                        info!(?error_response, "Authorization pending");
84                        Ok(AccessTokenResponse::AuthorizationPending(error_response))
85                    } else {
86                        error!(?error_response, "Unexpected error");
87                        Err(eyre::eyre!(
88                            "Unexpected error: {} - {}",
89                            error_response.error,
90                            error_response.error_description
91                        ))
92                    }
93                }
94            }
95        } else {
96            Err(eyre::eyre!("Failed to get access token"))
97        }
98    }
99}
100
101fn make_headers() -> HeaderMap {
102    let mut headers = reqwest::header::HeaderMap::new();
103    headers.insert(reqwest::header::ACCEPT, "application/json".parse().unwrap());
104    headers
105}