use std::time::{Duration, Instant};
use thiserror::Error;
use crate::credentials::Credential;
use crate::error::Error;
use crate::transport::http::HttpClient;
pub const GITHUB_HOST: &str = "github.com";
pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
pub const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
pub const VERIFICATION_URL: &str = "https://github.com/login/device";
pub const DEFAULT_SCOPE: &str = "repo";
pub const TOKEN_USERNAME: &str = "x-access-token";
const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
const DEFAULT_EXPIRES_IN: Duration = Duration::from_secs(900);
const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeviceAuthorization {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub interval: Duration,
pub expires_in: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollStatus {
Authorized(String),
Pending,
SlowDown {
interval: Duration,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceFlowStep {
RequestDeviceCode,
PollToken,
}
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum DeviceFlowError {
#[error("device flow HTTP request failed during {step:?}")]
Http {
step: DeviceFlowStep,
#[source]
source: Error,
},
#[error("device-code request rejected: {0}")]
DeviceCodeRejected(String),
#[error("device-code response is missing `{0}`")]
MissingField(&'static str),
#[error("device code expired before authorization")]
Expired,
#[error("authorization was denied")]
AccessDenied,
#[error("timed out waiting for authorization")]
TimedOut,
#[error("token request failed: {0}")]
TokenRejected(String),
#[error("unexpected response from the token endpoint")]
UnexpectedResponse,
}
pub trait PollClock {
fn now(&self) -> Instant;
fn sleep(&mut self, duration: Duration);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemPollClock;
impl PollClock for SystemPollClock {
fn now(&self) -> Instant {
Instant::now()
}
fn sleep(&mut self, duration: Duration) {
std::thread::sleep(duration);
}
}
pub struct DeviceFlow<'a> {
http: &'a dyn HttpClient,
client_id: &'a str,
scope: &'a str,
}
impl<'a> DeviceFlow<'a> {
#[must_use]
pub fn new(http: &'a dyn HttpClient, client_id: &'a str) -> Self {
Self {
http,
client_id,
scope: DEFAULT_SCOPE,
}
}
#[must_use]
pub fn with_scope(mut self, scope: &'a str) -> Self {
self.scope = scope;
self
}
pub fn request_device_code(&self) -> Result<DeviceAuthorization, DeviceFlowError> {
let body = form_encode(&[("client_id", self.client_id), ("scope", self.scope)]);
let fields = self.post_form(DEVICE_CODE_URL, &body, DeviceFlowStep::RequestDeviceCode)?;
if let Some(code) = form_field(&fields, "error") {
return Err(DeviceFlowError::DeviceCodeRejected(code.to_owned()));
}
let required = |key: &'static str| {
form_field(&fields, key)
.map(str::to_owned)
.ok_or(DeviceFlowError::MissingField(key))
};
Ok(DeviceAuthorization {
device_code: required("device_code")?,
user_code: required("user_code")?,
verification_uri: form_field(&fields, "verification_uri")
.unwrap_or(VERIFICATION_URL)
.to_owned(),
interval: seconds_field(&fields, "interval").unwrap_or(DEFAULT_INTERVAL),
expires_in: seconds_field(&fields, "expires_in").unwrap_or(DEFAULT_EXPIRES_IN),
})
}
pub fn poll(
&self,
device: &DeviceAuthorization,
current_interval: Duration,
) -> Result<PollStatus, DeviceFlowError> {
let body = form_encode(&[
("client_id", self.client_id),
("device_code", &device.device_code),
("grant_type", DEVICE_CODE_GRANT_TYPE),
]);
let fields = self.post_form(ACCESS_TOKEN_URL, &body, DeviceFlowStep::PollToken)?;
if let Some(token) = form_field(&fields, "access_token").filter(|t| !t.is_empty()) {
return Ok(PollStatus::Authorized(token.to_owned()));
}
match form_field(&fields, "error") {
Some("authorization_pending") => Ok(PollStatus::Pending),
Some("slow_down") => Ok(PollStatus::SlowDown {
interval: seconds_field(&fields, "interval")
.unwrap_or(current_interval + SLOW_DOWN_STEP),
}),
Some("expired_token") => Err(DeviceFlowError::Expired),
Some("access_denied") => Err(DeviceFlowError::AccessDenied),
Some(other) => Err(DeviceFlowError::TokenRejected(other.to_owned())),
None => Err(DeviceFlowError::UnexpectedResponse),
}
}
pub fn wait_for_token(
&self,
device: &DeviceAuthorization,
clock: &mut dyn PollClock,
) -> Result<String, DeviceFlowError> {
let deadline = clock.now() + device.expires_in;
let mut interval = device.interval.max(Duration::from_secs(1));
loop {
clock.sleep(interval);
if clock.now() >= deadline {
return Err(DeviceFlowError::TimedOut);
}
match self.poll(device, interval)? {
PollStatus::Authorized(token) => return Ok(token),
PollStatus::Pending => {}
PollStatus::SlowDown { interval: next } => interval = next,
}
}
}
fn post_form(
&self,
url: &str,
body: &str,
step: DeviceFlowStep,
) -> Result<Vec<(String, String)>, DeviceFlowError> {
let response = self
.http
.post(
url,
FORM_CONTENT_TYPE,
FORM_CONTENT_TYPE,
body.as_bytes(),
None,
)
.map_err(|source| DeviceFlowError::Http { step, source })?;
Ok(parse_form(&String::from_utf8_lossy(&response)))
}
}
#[must_use]
pub fn github_token_credential(token: Option<&str>) -> Credential {
Credential {
protocol: Some("https".to_owned()),
host: Some(GITHUB_HOST.to_owned()),
username: Some(TOKEN_USERNAME.to_owned()),
password: token.map(str::to_owned),
..Default::default()
}
}
#[must_use]
pub fn is_github_https_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("https://") else {
return false;
};
let authority = rest.split('/').next().unwrap_or_default();
let host_port = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
let host = host_port.split(':').next().unwrap_or_default();
host.eq_ignore_ascii_case(GITHUB_HOST) || host.eq_ignore_ascii_case("www.github.com")
}
fn form_field<'f>(fields: &'f [(String, String)], key: &str) -> Option<&'f str> {
fields
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
fn seconds_field(fields: &[(String, String)], key: &str) -> Option<Duration> {
form_field(fields, key)
.and_then(|v| v.parse().ok())
.map(Duration::from_secs)
}
fn form_encode(pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.map(|(k, v)| format!("{}={}", form_component_encode(k), form_component_encode(v)))
.collect::<Vec<_>>()
.join("&")
}
fn parse_form(body: &str) -> Vec<(String, String)> {
body.split('&')
.filter(|s| !s.is_empty())
.map(|pair| match pair.split_once('=') {
Some((k, v)) => (form_component_decode(k), form_component_decode(v)),
None => (form_component_decode(pair), String::new()),
})
.collect()
}
fn form_component_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for &byte in input.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(byte));
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn form_component_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut idx = 0;
while idx < bytes.len() {
match bytes[idx] {
b'+' => {
out.push(b' ');
idx += 1;
}
b'%' => {
let escaped = bytes
.get(idx + 1..idx + 3)
.filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match escaped {
Some(byte) => {
out.push(byte);
idx += 3;
}
None => {
out.push(b'%');
idx += 1;
}
}
}
other => {
out.push(other);
idx += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::collections::VecDeque;
use std::sync::Mutex;
use super::*;
type Request = (String, Vec<(String, String)>);
struct ScriptedHttp {
responses: Mutex<VecDeque<crate::error::Result<String>>>,
requests: Mutex<Vec<Request>>,
}
impl ScriptedHttp {
fn new(responses: Vec<crate::error::Result<&str>>) -> Self {
Self {
responses: Mutex::new(
responses
.into_iter()
.map(|r| r.map(str::to_owned))
.collect(),
),
requests: Mutex::new(Vec::new()),
}
}
fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
}
impl HttpClient for ScriptedHttp {
fn get(&self, url: &str, _git_protocol: Option<&str>) -> crate::error::Result<Vec<u8>> {
panic!("unexpected GET {url}")
}
fn post(
&self,
url: &str,
content_type: &str,
accept: &str,
body: &[u8],
_git_protocol: Option<&str>,
) -> crate::error::Result<Vec<u8>> {
assert_eq!(content_type, FORM_CONTENT_TYPE);
assert_eq!(accept, FORM_CONTENT_TYPE);
self.requests.lock().unwrap().push((
url.to_owned(),
parse_form(std::str::from_utf8(body).unwrap()),
));
self.responses
.lock()
.unwrap()
.pop_front()
.expect("scripted response")
.map(String::into_bytes)
}
}
struct FakeClock {
start: Instant,
elapsed: Duration,
sleeps: Vec<Duration>,
}
impl FakeClock {
fn new() -> Self {
Self {
start: Instant::now(),
elapsed: Duration::ZERO,
sleeps: Vec::new(),
}
}
}
impl PollClock for FakeClock {
fn now(&self) -> Instant {
self.start + self.elapsed
}
fn sleep(&mut self, duration: Duration) {
self.elapsed += duration;
self.sleeps.push(duration);
}
}
fn device(interval: u64, expires_in: u64) -> DeviceAuthorization {
DeviceAuthorization {
device_code: "dev-123".to_owned(),
user_code: "WDJB-MJHT".to_owned(),
verification_uri: VERIFICATION_URL.to_owned(),
interval: Duration::from_secs(interval),
expires_in: Duration::from_secs(expires_in),
}
}
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn form_round_trips_reserved_characters() {
let encoded = form_encode(&[
("grant_type", DEVICE_CODE_GRANT_TYPE),
("scope", "repo read:org"),
("odd", "a&b=c%d~"),
]);
assert_eq!(
encoded,
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code\
&scope=repo+read%3Aorg&odd=a%26b%3Dc%25d~"
);
let parsed = parse_form(&encoded);
assert_eq!(
form_field(&parsed, "grant_type"),
Some(DEVICE_CODE_GRANT_TYPE)
);
assert_eq!(form_field(&parsed, "scope"), Some("repo read:org"));
assert_eq!(form_field(&parsed, "odd"), Some("a&b=c%d~"));
}
#[test]
fn form_decode_keeps_malformed_escapes_literal() {
assert_eq!(form_component_decode("100%"), "100%");
assert_eq!(form_component_decode("%zz%4"), "%zz%4");
assert_eq!(form_component_decode("%+4"), "% 4");
assert_eq!(form_component_decode("%41%62"), "Ab");
let parsed = parse_form("flag&&k=v");
assert_eq!(form_field(&parsed, "flag"), Some(""));
assert_eq!(form_field(&parsed, "k"), Some("v"));
}
#[test]
fn request_device_code_parses_github_response() {
let http = ScriptedHttp::new(vec![Ok("device_code=3584d83&user_code=WDJB-MJHT\
&verification_uri=https%3A%2F%2Fgithub.com%2Flogin%2Fdevice\
&expires_in=600&interval=7")]);
let flow = DeviceFlow::new(&http, "client-abc");
let device = flow.request_device_code().unwrap();
assert_eq!(
device,
DeviceAuthorization {
device_code: "3584d83".to_owned(),
user_code: "WDJB-MJHT".to_owned(),
verification_uri: "https://github.com/login/device".to_owned(),
interval: secs(7),
expires_in: secs(600),
}
);
let requests = http.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].0, DEVICE_CODE_URL);
assert_eq!(form_field(&requests[0].1, "client_id"), Some("client-abc"));
assert_eq!(form_field(&requests[0].1, "scope"), Some(DEFAULT_SCOPE));
}
#[test]
fn request_device_code_defaults_optional_fields() {
let http = ScriptedHttp::new(vec![Ok("device_code=d&user_code=u&interval=soon")]);
let device = DeviceFlow::new(&http, "id")
.with_scope("read:user")
.request_device_code()
.unwrap();
assert_eq!(device.verification_uri, VERIFICATION_URL);
assert_eq!(device.interval, DEFAULT_INTERVAL);
assert_eq!(device.expires_in, DEFAULT_EXPIRES_IN);
assert_eq!(
form_field(&http.requests()[0].1, "scope"),
Some("read:user")
);
}
#[test]
fn request_device_code_reports_rejection_and_missing_fields() {
let http = ScriptedHttp::new(vec![
Ok("error=unauthorized_client&error_description=nope"),
Ok("user_code=u"),
Ok("device_code=d"),
]);
let flow = DeviceFlow::new(&http, "id");
assert!(matches!(
flow.request_device_code(),
Err(DeviceFlowError::DeviceCodeRejected(code)) if code == "unauthorized_client"
));
assert!(matches!(
flow.request_device_code(),
Err(DeviceFlowError::MissingField("device_code"))
));
assert!(matches!(
flow.request_device_code(),
Err(DeviceFlowError::MissingField("user_code"))
));
}
#[test]
fn transport_errors_name_the_failing_step() {
let http = ScriptedHttp::new(vec![
Err(Error::Auth("denied".to_owned())),
Err(Error::Message("offline".to_owned())),
]);
let flow = DeviceFlow::new(&http, "id");
assert!(matches!(
flow.request_device_code(),
Err(DeviceFlowError::Http {
step: DeviceFlowStep::RequestDeviceCode,
source: Error::Auth(_),
})
));
assert!(matches!(
flow.poll(&device(5, 900), secs(5)),
Err(DeviceFlowError::Http {
step: DeviceFlowStep::PollToken,
source: Error::Message(_),
})
));
}
#[test]
fn poll_sends_device_grant_and_classifies_responses() {
let http = ScriptedHttp::new(vec![
Ok("error=authorization_pending"),
Ok("error=slow_down&interval=12"),
Ok("error=slow_down"),
Ok("access_token=&error=authorization_pending"),
Ok("access_token=gho_abc&token_type=bearer&scope=repo"),
]);
let flow = DeviceFlow::new(&http, "id");
let dev = device(5, 900);
assert_eq!(flow.poll(&dev, secs(5)).unwrap(), PollStatus::Pending);
assert_eq!(
flow.poll(&dev, secs(5)).unwrap(),
PollStatus::SlowDown { interval: secs(12) }
);
assert_eq!(
flow.poll(&dev, secs(8)).unwrap(),
PollStatus::SlowDown { interval: secs(13) }
);
assert_eq!(flow.poll(&dev, secs(5)).unwrap(), PollStatus::Pending);
assert_eq!(
flow.poll(&dev, secs(5)).unwrap(),
PollStatus::Authorized("gho_abc".to_owned())
);
let request = &http.requests()[0];
assert_eq!(request.0, ACCESS_TOKEN_URL);
assert_eq!(form_field(&request.1, "client_id"), Some("id"));
assert_eq!(form_field(&request.1, "device_code"), Some("dev-123"));
assert_eq!(
form_field(&request.1, "grant_type"),
Some(DEVICE_CODE_GRANT_TYPE)
);
}
#[test]
fn poll_maps_terminal_oauth_errors() {
let http = ScriptedHttp::new(vec![
Ok("error=expired_token"),
Ok("error=access_denied"),
Ok("error=incorrect_device_code"),
Ok("token_type=bearer"),
]);
let flow = DeviceFlow::new(&http, "id");
let dev = device(5, 900);
assert!(matches!(
flow.poll(&dev, secs(5)),
Err(DeviceFlowError::Expired)
));
assert!(matches!(
flow.poll(&dev, secs(5)),
Err(DeviceFlowError::AccessDenied)
));
assert!(matches!(
flow.poll(&dev, secs(5)),
Err(DeviceFlowError::TokenRejected(code)) if code == "incorrect_device_code"
));
assert!(matches!(
flow.poll(&dev, secs(5)),
Err(DeviceFlowError::UnexpectedResponse)
));
}
#[test]
fn wait_for_token_honors_interval_and_slow_down() {
let http = ScriptedHttp::new(vec![
Ok("error=authorization_pending"),
Ok("error=slow_down"),
Ok("error=slow_down&interval=20"),
Ok("access_token=gho_tok"),
]);
let mut clock = FakeClock::new();
let token = DeviceFlow::new(&http, "id")
.wait_for_token(&device(5, 900), &mut clock)
.unwrap();
assert_eq!(token, "gho_tok");
assert_eq!(clock.sleeps, vec![secs(5), secs(5), secs(10), secs(20)]);
assert_eq!(http.requests().len(), 4);
}
#[test]
fn wait_for_token_waits_at_least_one_second() {
let http = ScriptedHttp::new(vec![Ok("access_token=t")]);
let mut clock = FakeClock::new();
DeviceFlow::new(&http, "id")
.wait_for_token(&device(0, 900), &mut clock)
.unwrap();
assert_eq!(clock.sleeps, vec![secs(1)]);
}
#[test]
fn wait_for_token_times_out_without_polling_past_expiry() {
let http = ScriptedHttp::new(vec![
Ok("error=authorization_pending"),
Ok("error=authorization_pending"),
]);
let mut clock = FakeClock::new();
let result = DeviceFlow::new(&http, "id").wait_for_token(&device(5, 12), &mut clock);
assert!(matches!(result, Err(DeviceFlowError::TimedOut)));
assert_eq!(http.requests().len(), 2);
assert_eq!(clock.sleeps, vec![secs(5), secs(5), secs(5)]);
}
#[test]
fn wait_for_token_stops_on_denial() {
let http = ScriptedHttp::new(vec![
Ok("error=authorization_pending"),
Ok("error=access_denied"),
]);
let mut clock = FakeClock::new();
let result = DeviceFlow::new(&http, "id").wait_for_token(&device(5, 900), &mut clock);
assert!(matches!(result, Err(DeviceFlowError::AccessDenied)));
}
#[test]
fn token_credential_targets_github_https() {
let store = github_token_credential(Some("gho_x"));
assert_eq!(store.protocol.as_deref(), Some("https"));
assert_eq!(store.host.as_deref(), Some(GITHUB_HOST));
assert_eq!(store.username.as_deref(), Some(TOKEN_USERNAME));
assert_eq!(store.password.as_deref(), Some("gho_x"));
assert!(store.is_complete());
assert_eq!(
store.target_url().as_deref(),
Some("https://x-access-token@github.com")
);
let erase = github_token_credential(None);
assert_eq!(erase.password, None);
assert_eq!(erase.target_url(), store.target_url());
}
#[test]
fn recognizes_https_github_urls() {
assert!(is_github_https_url("https://github.com/owner/repo.git"));
assert!(is_github_https_url(
"https://x-access-token@github.com/owner/repo.git"
));
assert!(is_github_https_url("https://user:pw@GitHub.com:443/o/r"));
assert!(is_github_https_url("https://www.github.com/owner/repo"));
assert!(is_github_https_url("https://github.com"));
assert!(!is_github_https_url("https://gitlab.com/owner/repo.git"));
assert!(!is_github_https_url("http://github.com/owner/repo.git"));
assert!(!is_github_https_url("git@github.com:owner/repo.git"));
assert!(!is_github_https_url("https://evil.com/github.com"));
assert!(!is_github_https_url("https://github.com.evil.com/o/r"));
assert!(!is_github_https_url("https://evil.com/x@github.com/o/r"));
}
}