#![warn(missing_docs)]
use chrono::{DateTime, Duration, Utc};
use log::info;
use reqwest::header::HeaderMap;
use serde::{Deserialize, Serialize};
use std::time;
const MACHINE_MAN_PREVIEW: &str =
"application/vnd.github.machine-man-preview+json";
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
#[error("JWT encoding failed: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
#[error("HTTP header encoding failed: {0}")]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error("HTTP request failed: {0}")]
ReqwestError(#[from] reqwest::Error),
#[error("system time error: {0}")]
TimeError(#[from] time::SystemTimeError),
}
#[derive(Debug, Serialize)]
struct JwtClaims {
iat: u64,
exp: u64,
iss: u64,
}
impl JwtClaims {
fn new(params: &GithubAuthParams) -> Result<JwtClaims, AuthError> {
let now = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)?
.as_secs();
Ok(JwtClaims {
iat: now,
exp: now + 60,
iss: params.app_id,
})
}
}
#[derive(Debug, Deserialize, Eq, PartialEq)]
struct RawInstallationAccessToken {
token: String,
expires_at: DateTime<Utc>,
}
async fn get_installation_token(
client: &reqwest::Client,
params: &GithubAuthParams,
) -> Result<RawInstallationAccessToken, AuthError> {
let claims = JwtClaims::new(params)?;
let header = jsonwebtoken::Header {
alg: jsonwebtoken::Algorithm::RS256,
..Default::default()
};
let private_key =
jsonwebtoken::EncodingKey::from_rsa_pem(¶ms.private_key)?;
let token = jsonwebtoken::encode(&header, &claims, &private_key)?;
let url = format!(
"https://api.github.com/app/installations/{}/access_tokens",
params.installation_id
);
Ok(client
.post(&url)
.bearer_auth(token)
.header("Accept", MACHINE_MAN_PREVIEW)
.send()
.await?
.error_for_status()?
.json()
.await?)
}
pub struct InstallationAccessToken {
pub client: reqwest::Client,
pub refresh_safety_margin: Duration,
token: String,
expires_at: DateTime<Utc>,
params: GithubAuthParams,
}
impl InstallationAccessToken {
pub async fn new(
params: GithubAuthParams,
) -> Result<InstallationAccessToken, AuthError> {
let client = reqwest::Client::builder()
.user_agent(¶ms.user_agent)
.build()?;
let raw = get_installation_token(&client, ¶ms).await?;
Ok(InstallationAccessToken {
client,
token: raw.token,
expires_at: raw.expires_at,
params,
refresh_safety_margin: Duration::minutes(1),
})
}
pub async fn header(&mut self) -> Result<HeaderMap, AuthError> {
self.refresh().await?;
let mut headers = HeaderMap::new();
let val = format!("token {}", self.token);
headers.insert("Authorization", val.parse()?);
Ok(headers)
}
fn needs_refresh(&self) -> bool {
let expires_at = self.expires_at - self.refresh_safety_margin;
expires_at <= Utc::now()
}
async fn refresh(&mut self) -> Result<(), AuthError> {
if self.needs_refresh() {
info!("refreshing installation token");
let raw =
get_installation_token(&self.client, &self.params).await?;
self.token = raw.token;
self.expires_at = raw.expires_at;
}
Ok(())
}
}
#[derive(Clone, Default)]
pub struct GithubAuthParams {
pub user_agent: String,
pub private_key: Vec<u8>,
pub installation_id: u64,
pub app_id: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_raw_installation_access_token_parse() {
let resp = r#"{
"token": "v1.1f699f1069f60xxx",
"expires_at": "2016-07-11T22:14:10Z"
}"#;
let token =
serde_json::from_str::<RawInstallationAccessToken>(resp).unwrap();
assert_eq!(
token,
RawInstallationAccessToken {
token: "v1.1f699f1069f60xxx".into(),
expires_at: Utc.ymd(2016, 7, 11).and_hms(22, 14, 10),
}
);
}
#[test]
fn test_needs_refresh() {
use std::thread::sleep;
let mut token = InstallationAccessToken {
client: reqwest::Client::new(),
token: "myToken".into(),
expires_at: Utc::now() + Duration::seconds(2),
params: GithubAuthParams::default(),
refresh_safety_margin: Duration::seconds(0),
};
assert!(!token.needs_refresh());
sleep(Duration::milliseconds(1500).to_std().unwrap());
assert!(!token.needs_refresh());
token.refresh_safety_margin = Duration::seconds(1);
assert!(token.needs_refresh());
}
}