use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use super::{
AuthConfig, AuthError, Clock, DEVICE_CODE_GRANT, LOSSY_WARNING, PollOutcome, RefreshClient,
SCOPE, Session, SessionStore, Sleeper, SystemClock, TokenGrant, classify_poll, error_code,
form_body, post_refresh, session_from_login, session_from_refresh,
};
#[derive(Clone, PartialEq, Eq)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: Option<String>,
pub expires_in: Duration,
pub interval: Duration,
}
impl fmt::Debug for DeviceCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceCode")
.field("device_code", &"<redacted>")
.field("user_code", &self.user_code)
.field("verification_uri", &self.verification_uri)
.field("verification_uri_complete", &self.verification_uri_complete)
.field("expires_in", &self.expires_in)
.field("interval", &self.interval)
.finish()
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawDeviceCode {
device_code: String,
user_code: String,
verification_uri: String,
#[serde(default)]
verification_uri_complete: Option<String>,
expires_in: u64,
interval: u64,
}
impl DeviceCode {
pub fn from_json(body: &str) -> Result<Self, AuthError> {
let raw: RawDeviceCode =
serde_json::from_str(body).map_err(|_| AuthError::Decode("device authorization"))?;
Ok(Self {
device_code: raw.device_code,
user_code: raw.user_code,
verification_uri: raw.verification_uri,
verification_uri_complete: raw.verification_uri_complete,
expires_in: Duration::from_secs(raw.expires_in),
interval: Duration::from_secs(raw.interval),
})
}
pub fn login_link(&self) -> String {
https(&self.verification_uri)
}
pub fn browser_link(&self) -> String {
match &self.verification_uri_complete {
Some(complete) => https(complete),
None => self.login_link(),
}
}
}
fn https(uri: &str) -> String {
if uri.starts_with("https://") || uri.starts_with("http://") {
uri.to_owned()
} else {
format!("https://{uri}")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Login {
pub session: Session,
pub client: RefreshClient,
}
pub struct DeviceFlow {
http: reqwest::Client,
config: AuthConfig,
clock: Arc<dyn Clock>,
sleeper: Arc<dyn Sleeper>,
}
impl fmt::Debug for DeviceFlow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceFlow")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl DeviceFlow {
pub fn new(config: AuthConfig) -> Result<Self, AuthError> {
Self::with_time(config, Arc::new(SystemClock), Arc::new(SystemClock))
}
pub fn with_time(
config: AuthConfig,
clock: Arc<dyn Clock>,
sleeper: Arc<dyn Sleeper>,
) -> Result<Self, AuthError> {
let http = reqwest::Client::builder()
.build()
.map_err(|e| AuthError::transport(&e))?;
Ok(Self {
http,
config,
clock,
sleeper,
})
}
pub async fn start_device_flow(&self) -> Result<DeviceCode, AuthError> {
let response = self
.http
.post(self.config.auth_url("device_authorization"))
.header("content-type", "application/x-www-form-urlencoded")
.body(form_body(&[
("client_id", &self.config.client_id),
("scope", SCOPE),
]))
.send()
.await
.map_err(|e| AuthError::transport(&e))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| AuthError::transport(&e))?;
if !status.is_success() {
return Err(AuthError::Http {
status: status.as_u16(),
code: error_code(&body).map(Into::into),
});
}
DeviceCode::from_json(&body)
}
pub async fn poll(
&self,
code: &DeviceCode,
interval: Duration,
) -> Result<PollOutcome, AuthError> {
let response = self
.http
.post(self.config.auth_url("token"))
.basic_auth(&self.config.client_id, Some(&self.config.client_secret))
.header("content-type", "application/x-www-form-urlencoded")
.body(form_body(&[
("client_id", &self.config.client_id),
("grant_type", DEVICE_CODE_GRANT),
("device_code", &code.device_code),
("scope", SCOPE),
]))
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
tracing::debug!(%error, "device-code poll got no response; polling on");
return Ok(PollOutcome::Pending { interval });
}
};
let status = response.status().as_u16();
let body = match response.text().await {
Ok(body) => body,
Err(_) => return Ok(PollOutcome::Pending { interval }),
};
classify_poll(status, &body, interval)
}
pub async fn wait_for_session(&self, code: &DeviceCode) -> Result<Session, AuthError> {
let deadline = self.clock.now() + code.expires_in;
let mut interval = code.interval;
loop {
match self.poll(code, interval).await? {
PollOutcome::Granted(grant) => {
return session_from_login(&grant, self.clock.now());
}
PollOutcome::Pending { interval: next } => interval = next,
}
if self.clock.now() >= deadline {
return Err(AuthError::CodeExpired);
}
self.sleeper.sleep(interval).await;
}
}
pub async fn complete_login(
&self,
code: &DeviceCode,
store: &dyn SessionStore,
) -> Result<Login, AuthError> {
let granted = self.wait_for_session(code).await?;
let login = match self.refresh_under_pkce(&granted).await {
Ok(session) => Login {
session,
client: RefreshClient::Pkce,
},
Err(error) => {
tracing::warn!(%error, "{LOSSY_WARNING}");
Login {
session: granted,
client: RefreshClient::DeviceFlow,
}
}
};
store.save(&login.session)?;
Ok(login)
}
async fn refresh_under_pkce(&self, granted: &Session) -> Result<Session, AuthError> {
let (status, body) = post_refresh(
&self.http,
&self.config,
RefreshClient::Pkce,
&granted.refresh_token,
)
.await?;
if !(200..300).contains(&status) {
return Err(AuthError::Http {
status,
code: error_code(&body).map(Into::into),
});
}
let grant = TokenGrant::from_json(&body)?;
Ok(session_from_refresh(granted, &grant, self.clock.now()))
}
}