1use std::time::Duration;
15
16pub const DEFAULT_OAUTH_URL: &str = "https://oauth.yandex.ru";
18
19const CLIENT_ID_ENV: &str = "YTCLI_OAUTH_CLIENT_ID";
20const CLIENT_SECRET_ENV: &str = "YTCLI_OAUTH_CLIENT_SECRET";
21const URL_ENV: &str = "YTCLI_OAUTH_URL";
22
23pub const READ_ONLY_SCOPE: &str = "tracker:read wiki:read";
26
27const DEFAULT_INTERVAL: u64 = 5;
29const SLOW_DOWN_STEP: u64 = 5;
31
32#[derive(Debug, thiserror::Error)]
33pub enum OAuthError {
34 #[error(
35 "this build has no OAuth application to sign in with; paste a token instead, \
36 or set {CLIENT_ID_ENV} and {CLIENT_SECRET_ENV} to an application of your own"
37 )]
38 NotConfigured,
39 #[error("transport error talking to Yandex OAuth")]
40 Transport(#[from] reqwest::Error),
41 #[error("the sign-in was declined in the browser")]
42 Denied,
43 #[error("the code expired before it was confirmed; run the command again")]
44 Expired,
45 #[error("Yandex OAuth refused the request: {0}")]
46 Rejected(String),
47 #[error("could not decode the Yandex OAuth response")]
48 Decode(#[source] serde_json::Error),
49}
50
51impl OAuthError {
52 #[must_use]
53 pub fn exit_code(&self) -> crate::exit::ExitCode {
54 match self {
55 Self::Transport(_) | Self::Decode(_) => crate::exit::ExitCode::Failure,
56 Self::NotConfigured | Self::Denied | Self::Expired | Self::Rejected(_) => {
57 crate::exit::ExitCode::Auth
58 }
59 }
60 }
61}
62
63#[derive(Debug, Clone, serde::Deserialize)]
65pub struct DeviceCode {
66 #[serde(rename = "device_code")]
68 secret: String,
69 pub user_code: String,
71 pub verification_url: String,
72 #[serde(default)]
73 interval: Option<u64>,
74 #[serde(default)]
76 pub expires_in: Option<u64>,
77}
78
79#[derive(Clone, serde::Deserialize)]
81pub struct Grant {
82 pub access_token: String,
83 #[serde(default)]
84 pub refresh_token: Option<String>,
85}
86
87impl std::fmt::Debug for Grant {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("Grant")
91 .field("refresh_token", &self.refresh_token.is_some())
92 .finish_non_exhaustive()
93 }
94}
95
96enum Poll {
98 Pending,
99 SlowDown,
100 Granted(Grant),
101}
102
103#[derive(serde::Deserialize)]
104struct Failure {
105 error: String,
106 #[serde(default)]
107 error_description: Option<String>,
108}
109
110#[derive(Clone)]
112pub struct App {
113 http: reqwest::Client,
114 base_url: String,
115 client_id: String,
116 client_secret: String,
117}
118
119impl std::fmt::Debug for App {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("App")
122 .field("base_url", &self.base_url)
123 .field("client_id", &self.client_id)
124 .finish_non_exhaustive()
125 }
126}
127
128fn credentials() -> Option<(String, String)> {
135 let runtime = std::env::var(CLIENT_ID_ENV)
136 .ok()
137 .filter(|id| !id.is_empty())
138 .map(|id| (id, std::env::var(CLIENT_SECRET_ENV).unwrap_or_default()));
139 let built = option_env!("YTCLI_OAUTH_CLIENT_ID")
140 .zip(option_env!("YTCLI_OAUTH_CLIENT_SECRET"))
141 .map(|(id, secret)| (id.to_owned(), secret.to_owned()));
142
143 runtime
144 .or(built)
145 .filter(|(id, secret)| !id.is_empty() && !secret.is_empty())
146}
147
148impl App {
149 pub fn from_environment() -> Result<Self, OAuthError> {
151 let (client_id, client_secret) = credentials().ok_or(OAuthError::NotConfigured)?;
152 let base_url = std::env::var(URL_ENV)
153 .ok()
154 .filter(|url| !url.is_empty())
155 .unwrap_or_else(|| DEFAULT_OAUTH_URL.to_owned());
156 Self::new(&base_url, client_id, client_secret)
157 }
158
159 pub fn new(
161 base_url: &str,
162 client_id: String,
163 client_secret: String,
164 ) -> Result<Self, OAuthError> {
165 let http = reqwest::Client::builder()
166 .timeout(Duration::from_secs(30))
167 .user_agent(concat!("ytcli/", env!("CARGO_PKG_VERSION")))
168 .build()?;
169
170 Ok(Self {
171 http,
172 base_url: base_url.trim_end_matches('/').to_owned(),
173 client_id,
174 client_secret,
175 })
176 }
177
178 #[must_use]
180 pub fn is_configured() -> bool {
181 credentials().is_some()
182 }
183
184 pub async fn request_code(&self, scope: Option<&str>) -> Result<DeviceCode, OAuthError> {
189 let mut fields = vec![
190 ("client_id", self.client_id.as_str()),
191 ("device_name", "ytcli"),
192 ];
193 if let Some(scope) = scope {
194 fields.push(("scope", scope));
195 }
196
197 let response = self.post("/device/code", &fields).await?;
198 let status = response.status();
199 let body = response.text().await?;
200 if !status.is_success() {
201 return Err(failure(status, &body));
202 }
203 serde_json::from_str(&body).map_err(OAuthError::Decode)
204 }
205
206 pub async fn try_grant(&self, code: &DeviceCode) -> Result<Option<Grant>, OAuthError> {
209 match self.poll(code).await? {
210 Poll::Granted(grant) => Ok(Some(grant)),
211 Poll::Pending | Poll::SlowDown => Ok(None),
212 }
213 }
214
215 pub async fn await_grant(&self, code: &DeviceCode) -> Result<Grant, OAuthError> {
217 let mut interval = code.interval.unwrap_or(DEFAULT_INTERVAL);
218 let deadline = code
219 .expires_in
220 .map(|seconds| std::time::Instant::now() + Duration::from_secs(seconds));
221
222 loop {
223 match self.poll(code).await? {
224 Poll::Granted(grant) => return Ok(grant),
225 Poll::SlowDown => interval += SLOW_DOWN_STEP,
226 Poll::Pending => {}
227 }
228
229 if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
230 return Err(OAuthError::Expired);
231 }
232 tokio::time::sleep(Duration::from_secs(interval)).await;
233 }
234 }
235
236 async fn poll(&self, code: &DeviceCode) -> Result<Poll, OAuthError> {
237 self.exchange(&[
238 ("grant_type", "device_code"),
239 ("code", code.secret.as_str()),
240 ])
241 .await
242 }
243
244 pub async fn refresh(&self, refresh_token: &str) -> Result<Grant, OAuthError> {
246 match self
247 .exchange(&[
248 ("grant_type", "refresh_token"),
249 ("refresh_token", refresh_token),
250 ])
251 .await?
252 {
253 Poll::Granted(grant) => Ok(grant),
254 Poll::Pending | Poll::SlowDown => Err(OAuthError::Rejected(
257 "an unexpected pending answer to a refresh".to_owned(),
258 )),
259 }
260 }
261
262 async fn exchange(&self, grant: &[(&str, &str)]) -> Result<Poll, OAuthError> {
263 let mut fields = grant.to_vec();
264 fields.push(("client_id", self.client_id.as_str()));
265 fields.push(("client_secret", self.client_secret.as_str()));
266
267 let response = self.post("/token", &fields).await?;
268 let status = response.status();
269 let body = response.text().await?;
270 if status.is_success() {
271 return serde_json::from_str(&body)
272 .map(Poll::Granted)
273 .map_err(OAuthError::Decode);
274 }
275
276 match serde_json::from_str::<Failure>(&body) {
277 Ok(failure) if failure.error == "authorization_pending" => Ok(Poll::Pending),
278 Ok(failure) if failure.error == "slow_down" => Ok(Poll::SlowDown),
279 _ => Err(failure(status, &body)),
280 }
281 }
282
283 async fn post(
284 &self,
285 path: &str,
286 fields: &[(&str, &str)],
287 ) -> Result<reqwest::Response, OAuthError> {
288 Ok(self
289 .http
290 .post(format!("{}{path}", self.base_url))
291 .header(
292 reqwest::header::CONTENT_TYPE,
293 "application/x-www-form-urlencoded",
294 )
295 .body(form(fields))
296 .send()
297 .await?)
298 }
299}
300
301fn failure(status: reqwest::StatusCode, body: &str) -> OAuthError {
303 match serde_json::from_str::<Failure>(body) {
304 Ok(failure) => match failure.error.as_str() {
305 "access_denied" => OAuthError::Denied,
306 "expired_token" => OAuthError::Expired,
307 "invalid_client" => OAuthError::Rejected(format!(
311 "invalid_client — {}. The application's id or secret is wrong: \
312 check {CLIENT_ID_ENV} and {CLIENT_SECRET_ENV}, or the build that set them",
313 failure
314 .error_description
315 .as_deref()
316 .unwrap_or("unknown client")
317 )),
318 _ => OAuthError::Rejected(failure.error_description.map_or_else(
319 || failure.error.clone(),
320 |description| format!("{} — {description}", failure.error),
321 )),
322 },
323 Err(_) => OAuthError::Rejected(status.to_string()),
324 }
325}
326
327fn form(fields: &[(&str, &str)]) -> String {
330 fields
331 .iter()
332 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
333 .collect::<Vec<_>>()
334 .join("&")
335}
336
337fn encode(text: &str) -> String {
338 use std::fmt::Write as _;
339
340 let mut encoded = String::with_capacity(text.len());
341 for byte in text.bytes() {
342 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
343 encoded.push(char::from(byte));
344 } else {
345 let _ = write!(encoded, "%{byte:02X}");
346 }
347 }
348 encoded
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
358 fn a_scope_list_survives_the_form_encoding() {
359 assert_eq!(
360 form(&[("scope", "tracker:read wiki:read")]),
361 "scope=tracker%3Aread%20wiki%3Aread"
362 );
363 }
364}