1use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde::Deserialize;
8
9use super::{
10 AuthConfig, AuthError, Clock, DEVICE_CODE_GRANT, LOSSY_WARNING, PollOutcome, RefreshClient,
11 SCOPE, Session, SessionStore, Sleeper, SystemClock, TokenGrant, classify_poll, error_code,
12 form_body, post_refresh, session_from_login, session_from_refresh,
13};
14
15#[derive(Clone, PartialEq, Eq)]
19pub struct DeviceCode {
20 pub device_code: String,
21 pub user_code: String,
23 pub verification_uri: String,
25 pub verification_uri_complete: Option<String>,
27 pub expires_in: Duration,
29 pub interval: Duration,
31}
32
33impl fmt::Debug for DeviceCode {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 f.debug_struct("DeviceCode")
36 .field("device_code", &"<redacted>")
37 .field("user_code", &self.user_code)
38 .field("verification_uri", &self.verification_uri)
39 .field("verification_uri_complete", &self.verification_uri_complete)
40 .field("expires_in", &self.expires_in)
41 .field("interval", &self.interval)
42 .finish()
43 }
44}
45
46#[derive(Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct RawDeviceCode {
50 device_code: String,
51 user_code: String,
52 verification_uri: String,
53 #[serde(default)]
54 verification_uri_complete: Option<String>,
55 expires_in: u64,
56 interval: u64,
57}
58
59impl DeviceCode {
60 pub fn from_json(body: &str) -> Result<Self, AuthError> {
62 let raw: RawDeviceCode =
63 serde_json::from_str(body).map_err(|_| AuthError::Decode("device authorization"))?;
64 Ok(Self {
65 device_code: raw.device_code,
66 user_code: raw.user_code,
67 verification_uri: raw.verification_uri,
68 verification_uri_complete: raw.verification_uri_complete,
69 expires_in: Duration::from_secs(raw.expires_in),
70 interval: Duration::from_secs(raw.interval),
71 })
72 }
73
74 pub fn login_link(&self) -> String {
76 https(&self.verification_uri)
77 }
78
79 pub fn browser_link(&self) -> String {
82 match &self.verification_uri_complete {
83 Some(complete) => https(complete),
84 None => self.login_link(),
85 }
86 }
87}
88
89fn https(uri: &str) -> String {
91 if uri.starts_with("https://") || uri.starts_with("http://") {
92 uri.to_owned()
93 } else {
94 format!("https://{uri}")
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Login {
101 pub session: Session,
102 pub client: RefreshClient,
106}
107
108pub struct DeviceFlow {
111 http: reqwest::Client,
112 config: AuthConfig,
113 clock: Arc<dyn Clock>,
114 sleeper: Arc<dyn Sleeper>,
115}
116
117impl fmt::Debug for DeviceFlow {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.debug_struct("DeviceFlow")
120 .field("config", &self.config)
121 .finish_non_exhaustive()
122 }
123}
124
125impl DeviceFlow {
126 pub fn new(config: AuthConfig) -> Result<Self, AuthError> {
128 Self::with_time(config, Arc::new(SystemClock), Arc::new(SystemClock))
129 }
130
131 pub fn with_time(
133 config: AuthConfig,
134 clock: Arc<dyn Clock>,
135 sleeper: Arc<dyn Sleeper>,
136 ) -> Result<Self, AuthError> {
137 let http = reqwest::Client::builder()
138 .build()
139 .map_err(|e| AuthError::transport(&e))?;
140 Ok(Self {
141 http,
142 config,
143 clock,
144 sleeper,
145 })
146 }
147
148 pub async fn start_device_flow(&self) -> Result<DeviceCode, AuthError> {
150 let response = self
151 .http
152 .post(self.config.auth_url("device_authorization"))
153 .header("content-type", "application/x-www-form-urlencoded")
154 .body(form_body(&[
155 ("client_id", &self.config.client_id),
156 ("scope", SCOPE),
157 ]))
158 .send()
159 .await
160 .map_err(|e| AuthError::transport(&e))?;
161 let status = response.status();
162 let body = response
163 .text()
164 .await
165 .map_err(|e| AuthError::transport(&e))?;
166 if !status.is_success() {
167 return Err(AuthError::Http {
168 status: status.as_u16(),
169 code: error_code(&body).map(Into::into),
170 });
171 }
172 DeviceCode::from_json(&body)
173 }
174
175 pub async fn poll(
178 &self,
179 code: &DeviceCode,
180 interval: Duration,
181 ) -> Result<PollOutcome, AuthError> {
182 let response = self
183 .http
184 .post(self.config.auth_url("token"))
185 .basic_auth(&self.config.client_id, Some(&self.config.client_secret))
186 .header("content-type", "application/x-www-form-urlencoded")
187 .body(form_body(&[
188 ("client_id", &self.config.client_id),
189 ("grant_type", DEVICE_CODE_GRANT),
190 ("device_code", &code.device_code),
191 ("scope", SCOPE),
192 ]))
193 .send()
194 .await;
195 let response = match response {
196 Ok(response) => response,
197 Err(error) => {
198 tracing::debug!(%error, "device-code poll got no response; polling on");
199 return Ok(PollOutcome::Pending { interval });
200 }
201 };
202 let status = response.status().as_u16();
203 let body = match response.text().await {
204 Ok(body) => body,
205 Err(_) => return Ok(PollOutcome::Pending { interval }),
206 };
207 classify_poll(status, &body, interval)
208 }
209
210 pub async fn wait_for_session(&self, code: &DeviceCode) -> Result<Session, AuthError> {
213 let deadline = self.clock.now() + code.expires_in;
214 let mut interval = code.interval;
215 loop {
216 match self.poll(code, interval).await? {
217 PollOutcome::Granted(grant) => {
218 return session_from_login(&grant, self.clock.now());
219 }
220 PollOutcome::Pending { interval: next } => interval = next,
221 }
222 if self.clock.now() >= deadline {
223 return Err(AuthError::CodeExpired);
224 }
225 self.sleeper.sleep(interval).await;
226 }
227 }
228
229 pub async fn complete_login(
234 &self,
235 code: &DeviceCode,
236 store: &dyn SessionStore,
237 ) -> Result<Login, AuthError> {
238 let granted = self.wait_for_session(code).await?;
239 let login = match self.refresh_under_pkce(&granted).await {
240 Ok(session) => Login {
241 session,
242 client: RefreshClient::Pkce,
243 },
244 Err(error) => {
245 tracing::warn!(%error, "{LOSSY_WARNING}");
246 Login {
247 session: granted,
248 client: RefreshClient::DeviceFlow,
249 }
250 }
251 };
252 store.save(&login.session)?;
253 Ok(login)
254 }
255
256 async fn refresh_under_pkce(&self, granted: &Session) -> Result<Session, AuthError> {
259 let (status, body) = post_refresh(
260 &self.http,
261 &self.config,
262 RefreshClient::Pkce,
263 &granted.refresh_token,
264 )
265 .await?;
266 if !(200..300).contains(&status) {
267 return Err(AuthError::Http {
268 status,
269 code: error_code(&body).map(Into::into),
270 });
271 }
272 let grant = TokenGrant::from_json(&body)?;
273 Ok(session_from_refresh(granted, &grant, self.clock.now()))
274 }
275}