1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
use std::env;
use std::fs;
use std::io::Write;
use std::thread;
use std::time::Duration;

use rand::{distributions::Alphanumeric, Rng};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use url::Url;

use crate::patch::webbrowser::{self, BrowserOptions};
use crate::server::SERVER_URL_TOKEN;
use crate::{Error, Result};

pub const GITHUB_OAUTH_CLIENT_ID: &str = "265cd618948a2e58042e";
pub const GITHUB_OAUTH_BASE_URL: &str = "https://github.com/login/oauth/authorize";
pub const GITHUB_GISTS_API_URL: &str = "https://api.github.com/gists";

#[derive(Clone, Debug, Serialize)]
pub struct Oauth {
    pub state: String,
    pub token: Option<Token>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Token {
    pub access_token: String,
    pub token_type: String,
    pub scope: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct CreateResponse {
    pub url: String,
    pub forks_url: String,
    pub commits_url: String,
    pub id: String,
    pub node_id: String,
    pub git_pull_url: String,
    pub git_push_url: String,
    pub html_url: String,
    pub created_at: String,
    pub updated_at: String,
    pub description: String,
    pub comments: i32,
    pub comments_url: String,
}

impl Oauth {
    /// Looks for token in project config dir and initializes state.
    /// Will not fail if token file is missing.
    ///
    /// # Errors
    ///
    /// Fails if cannot read token file
    pub fn new() -> Result<Self> {
        let config = gistit_project::path::config()?;
        let token_path = config.join("github");
        let state = unguessable_state();

        let token = if fs::metadata(&token_path).is_ok() {
            Some(serde_json::from_str(&fs::read_to_string(&token_path)?)?)
        } else {
            None
        };

        Ok(Self { state, token })
    }

    /// Attempts to open a web browser and authorize GitHub OAuth
    ///
    /// # Errors
    ///
    /// Fails if cannot find a suitable browser or outside a display environment
    pub fn authorize(&self) -> Result<()> {
        let url = Url::parse_with_params(
            GITHUB_OAUTH_BASE_URL,
            &[
                ("client_id", GITHUB_OAUTH_CLIENT_ID),
                ("state", &self.state),
                ("scope", "gist"),
            ],
        )?;

        // Can't open browser under ssh
        if env::var("SSH_CLIENT").is_ok() {
            return Err(Error::OAuth(url.to_string()));
        }

        webbrowser::open_browser_with_options(BrowserOptions {
            url: String::from(url.as_str()),
            suppress_output: Some(true),
            browser: Some(webbrowser::Browser::Default),
        })
        .map_err(|_| Error::OAuth(url.to_string()))?;

        Ok(())
    }

    /// Polls server for authenticated token every 2 seconds
    ///
    /// # Errors
    ///
    /// Fails after 3 retries
    pub async fn poll_token(&mut self) -> Result<()> {
        let mut retry = 0_usize;
        let token: Token = loop {
            let response = reqwest::Client::new()
                .post(SERVER_URL_TOKEN.to_string())
                .json(self)
                .send()
                .await?;

            match response.status() {
                StatusCode::NOT_FOUND => {
                    if retry < 7 {
                        thread::sleep(Duration::from_secs(3));
                        retry += 1;
                    } else {
                        return Err(Error::OAuth("could not authorize".to_owned()));
                    }
                }
                StatusCode::OK => {
                    break response.json().await?;
                }
                _ => return Err(Error::Server("unexpected response")),
            }
        };

        let config = gistit_project::path::config()?;
        fs::File::create(config.join("github"))?.write_all(&serde_json::to_vec(&token)?)?;

        self.token = Some(token);

        Ok(())
    }

    #[must_use]
    pub const fn token(&self) -> Option<&Token> {
        self.token.as_ref()
    }
}

#[must_use]
pub fn unguessable_state() -> String {
    rand::thread_rng()
        .sample_iter(&Alphanumeric)
        .take(8)
        .map(char::from)
        .collect()
}