Skip to main content

ytcli/
oauth.rs

1//! Signing in through Yandex OAuth, so nobody has to paste a token.
2//!
3//! The device-code flow: ask for a short code, show it, and poll until the
4//! person has confirmed it in a browser — on this machine or any other, which is
5//! what makes it work over SSH and inside an agent's sandbox too. The grant comes
6//! with a refresh token, which is what `auth refresh` spends.
7//!
8//! Exchanging a code for a token needs an application's id and secret. The
9//! shared ytcli application's pair is compiled in from the environment of the
10//! build, so it never lives in the tree; the same variables at run time win, for
11//! anyone who would rather sign in through an application of their own. Why a
12//! secret shipped inside a binary is acceptable: `docs/adr/0008-device-sign-in.md`.
13
14use std::time::Duration;
15
16/// Where Yandex OAuth lives. Overridable so tests can point at a stub.
17pub 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
23/// What `--read-only` asks for instead of everything the application may grant:
24/// reading Tracker and reading the Wiki, and nothing that writes to either.
25pub const READ_ONLY_SCOPE: &str = "tracker:read wiki:read";
26
27/// Used when Yandex does not say how long to wait between polls.
28const DEFAULT_INTERVAL: u64 = 5;
29/// Yandex's own addition to the interval when it answers `slow_down`.
30const 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/// A code waiting to be confirmed.
64#[derive(Debug, Clone, serde::Deserialize)]
65pub struct DeviceCode {
66    /// What the polls quote back; never shown.
67    #[serde(rename = "device_code")]
68    secret: String,
69    /// What the person types into the page at `verification_url`.
70    pub user_code: String,
71    pub verification_url: String,
72    #[serde(default)]
73    interval: Option<u64>,
74    /// Seconds until the code stops being accepted.
75    #[serde(default)]
76    pub expires_in: Option<u64>,
77}
78
79/// A token, and what renews it.
80#[derive(Clone, serde::Deserialize)]
81pub struct Grant {
82    pub access_token: String,
83    #[serde(default)]
84    pub refresh_token: Option<String>,
85}
86
87// Derived Debug would print both tokens into any log that formats a grant.
88impl 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
96/// One answer to a poll.
97enum 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/// An OAuth application to sign in through.
111#[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
128/// The application's id and secret: the run-time environment's pair if it has
129/// one, otherwise the pair this binary was built with.
130///
131/// Taken as a pair, never mixed: an id from one source and a secret from the
132/// other is an application that does not exist, and Yandex's answer to it —
133/// `invalid_client` — says nothing about why.
134fn 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    /// The application this binary signs in through, if it has one.
150    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    /// An application given explicitly, at an explicit address.
160    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    /// Whether signing in through the browser is possible at all.
179    #[must_use]
180    pub fn is_configured() -> bool {
181        credentials().is_some()
182    }
183
184    /// `POST /device/code` — a code for the person to confirm.
185    ///
186    /// Without a scope the token gets everything the application was registered
187    /// with; with one, only that.
188    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    /// Ask once: the grant if the code has been confirmed already, `None` if
207    /// not yet.
208    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    /// Poll until the code is confirmed, declined, or runs out.
216    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    /// `POST /token` with `grant_type=refresh_token`.
245    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            // Neither belongs to a refresh; seeing one means Yandex answered a
255            // question that was not asked.
256            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
301/// Turn an error answer into the error that says what to do about it.
302fn 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            // Yandex checks the secret only once the code is confirmed, so this
308            // arrives after the person has done everything right, and has to
309            // say it was not their doing.
310            "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
327/// `application/x-www-form-urlencoded`, by hand: reqwest's encoder sits behind a
328/// feature, and five fields do not justify turning it on.
329fn 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    /// A scope list is two words joined by a space, each with a colon in it;
356    /// sent unencoded, Yandex reads the second word as a stray parameter.
357    #[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}