Skip to main content

runner_manager_github/
device_flow.rs

1// owner: c2-device-flow-auth
2
3//! The OAuth 2.0 Device Authorization Grant against the published GitHub App.
4//!
5//! This is the **only** authentication path the product has (D3, D16). It needs
6//! a public `client_id` and nothing else: no client secret, no redirect
7//! listener, no loopback port, and no server anywhere in the design. GitHub
8//! documents that a public client cannot secure a client secret, and this design
9//! never tries to (`07-security.md`, "Authentication model").
10//!
11//! # The shape of a login
12//!
13//! 1. [`DeviceFlow::start`] posts the `client_id` and gets back a user code, a
14//!    device code, an expiry, and a polling interval.
15//! 2. The caller displays the user code and the canonical
16//!    [`crate::DEVICE_VERIFICATION_PATH`] URL. It displays **nothing else** —
17//!    see "Phishing" below.
18//! 3. [`DeviceFlow::complete`] polls until the user approves, honouring the
19//!    interval and every documented error in the matrix.
20//! 4. The token is **returned**. Nothing here writes it anywhere; `d2` owns the
21//!    machine-scoped store and `f1` owns the wiring.
22//!
23//! # The error matrix is four outcomes, not one failure
24//!
25//! `authorization_pending`, `slow_down`, `expired_token` and `access_denied` are
26//! four different things that each need a different response, and collapsing
27//! them into one generic error is how a CLI ends up telling a user who *declined*
28//! the authorization to try again:
29//!
30//! | GitHub `error` | Here | Caller does |
31//! |---|---|---|
32//! | `authorization_pending` | [`PollOutcome::Pending`] | keep polling, same interval |
33//! | `slow_down` | [`PollOutcome::SlowDown`] | keep polling, **longer** interval |
34//! | `expired_token` | [`DeviceFlowError::Expired`] | start a whole new login |
35//! | `access_denied` | [`DeviceFlowError::AccessDenied`] | stop; the user said no |
36//!
37//! Only the first two are recoverable, and [`DeviceFlowError::is_retryable`]
38//! says so for the rest.
39//!
40//! # Phishing
41//!
42//! `07-security.md`'s threat table names "a phishing page imitates the
43//! device-flow prompt to harvest a code", with the control "the tool prints the
44//! canonical `github.com/login/device` URL and never proxies or embeds the
45//! approval page". Two things implement it. The tool prints
46//! [`crate::Endpoints::verification_url`], a compiled-in constant, rather than
47//! whatever a response contained; and [`DeviceFlow::start`] *rejects* a
48//! `verification_uri` whose origin is not the configured GitHub web host, so a
49//! response that tries to redirect a user elsewhere is an error rather than
50//! something the CLI renders.
51//!
52//! # Renewal
53//!
54//! There is none, and none may be added. The published App opts out of
55//! user-token expiration, so GitHub issues no renewal token with the access
56//! token; renewing a user token requires the client secret, which a public
57//! client cannot hold. `lib.rs`'s
58//! `tests::no_renewal_path_and_no_confidential_credential_in_this_crate` scans
59//! this file and `lib.rs` for the identifiers such a path would need — after
60//! lower-casing and removing `_`, so that every casing a Rust identifier can
61//! take is the same needle. That normalisation is why the prose here writes
62//! "renewal token" and "client secret" as separate words.
63//!
64//! `-` is deliberately *not* removed from a `.rs` file, because a Rust
65//! identifier cannot contain one and removing it made ordinary hyphenated
66//! English trip the gate. The manifest is normalised the other way, where `-` is
67//! a kebab-case word separator rather than a hyphen. `lib.rs`'s
68//! `tests::normalise_source` carries the reasoning and the residual gap.
69
70use std::{fmt, time::Duration};
71
72use secrecy::{ExposeSecret, SecretString};
73use serde::Deserialize;
74use url::Url;
75
76use crate::{
77    AppRegistration, ConfigError, DEFAULT_REQUEST_TIMEOUT, Endpoints, Sleeper, USER_AGENT,
78    UserAccessToken,
79};
80
81/// The grant type the device flow's token request carries, verbatim from
82/// RFC 8628.
83pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
84
85/// The minimum a `slow_down` lengthens the poll interval by.
86///
87/// RFC 8628 §3.5: on `slow_down` "the interval MUST be increased by 5 seconds
88/// for this and all subsequent requests". GitHub also returns the new interval
89/// in the response body; [`DeviceFlow::poll_once`] takes whichever is larger, so
90/// the RFC's floor holds even against a response that omits or under-states it.
91pub const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5);
92
93/// The interval used when a response omits one. RFC 8628's default.
94pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5);
95
96/// How many consecutive retryable failures one [`DeviceFlow::complete`] loop
97/// absorbs before giving up.
98///
99/// A device login polls for up to fifteen minutes, on the product's *only*
100/// authentication path, while a human reads a code and walks to a browser.
101/// Propagating the first dropped packet aborts all of that and makes the user
102/// start again and re-approve — a login destroyed by a blip that the very next
103/// poll, five seconds later, would not have noticed. Five is enough to ride out
104/// a transient network or gateway fault and few enough that a genuinely
105/// unreachable GitHub is still reported promptly rather than polled at for the
106/// whole expiry window.
107pub const MAX_TRANSPORT_RETRIES: u32 = 5;
108
109// ---------------------------------------------------------------------------
110// Errors
111// ---------------------------------------------------------------------------
112
113/// Every way a login can fail.
114///
115/// GitHub's `error_description` is deliberately **not** carried into any of
116/// these. It is free text from a remote party, and this crate's redaction gate
117/// is far easier to keep true by construction than by auditing what a remote
118/// string happened to contain. The machine-readable `error` code is documented
119/// and exhaustive, and it is what a caller branches on anyway.
120#[derive(Debug, thiserror::Error)]
121pub enum DeviceFlowError {
122    /// The user declined. Terminal, and **not** a failure to retry: retrying
123    /// re-prompts someone who already said no.
124    #[error("the login was declined on GitHub")]
125    AccessDenied,
126
127    /// The device code timed out before approval. A whole new login is needed —
128    /// the same code cannot be re-presented.
129    #[error("the device code expired before the login was approved; start `auth login` again")]
130    Expired,
131
132    /// GitHub does not recognise the device code. A new login is needed.
133    #[error("GitHub did not recognise the device code; start `auth login` again")]
134    IncorrectDeviceCode,
135
136    /// The App registration itself is wrong — device flow not enabled, or a bad
137    /// `client_id`. No amount of retrying helps; a maintainer must fix the
138    /// published App (`06-migration-rollout.md`, Phase 0).
139    #[error("the published GitHub App is misconfigured for the device flow: GitHub said {code:?}")]
140    AppMisconfigured { code: String },
141
142    #[error("GitHub returned an unrecognised device-flow error: {code:?}")]
143    Unexpected { code: String },
144
145    /// A `verification_uri` that is not on the configured GitHub web host.
146    /// The user's code must only ever be typed on GitHub's own domain.
147    #[error(
148        "GitHub returned a verification URL on {origin:?}, which is not the canonical device \
149         page; refusing to display it"
150    )]
151    UntrustedVerificationUri { origin: String },
152
153    #[error("GitHub was unreachable")]
154    Transport(#[source] reqwest::Error),
155
156    #[error("GitHub returned {status} for the device-flow {stage}")]
157    Status { status: u16, stage: &'static str },
158
159    #[error("a device-flow {stage} response could not be decoded")]
160    Decode {
161        stage: &'static str,
162        #[source]
163        source: serde_json::Error,
164    },
165
166    #[error("GitHub returned {value:?} for {what}, which this client cannot use")]
167    Malformed { what: &'static str, value: String },
168
169    #[error(transparent)]
170    Config(#[from] ConfigError),
171}
172
173impl DeviceFlowError {
174    /// Whether presenting the *same* login again could succeed.
175    ///
176    /// The two recoverable protocol states, `authorization_pending` and
177    /// `slow_down`, are [`PollOutcome`]s and never become errors at all, so this
178    /// is not about them. It is about the two failures that say nothing about
179    /// the login: a dropped connection and a `5xx` from a gateway. The device
180    /// code is still live in both cases, and the next poll can still succeed —
181    /// which is why [`DeviceFlow::complete`] absorbs up to
182    /// [`MAX_TRANSPORT_RETRIES`] of them rather than destroying a login over a
183    /// blip.
184    ///
185    /// Everything else is `false`, and deliberately so. A caller that retried
186    /// [`DeviceFlowError::AccessDenied`] would re-prompt a user who has already
187    /// refused; one that retried [`DeviceFlowError::Expired`] would present a
188    /// code GitHub has already discarded. Those are the cases this method exists
189    /// to keep `false`, and no `5xx` handling may be allowed to blur them.
190    #[must_use]
191    pub fn is_retryable(&self) -> bool {
192        match self {
193            Self::Transport(_) => true,
194            // A `5xx` is the far end failing, not an answer about this login. A
195            // `4xx` is an answer.
196            Self::Status { status, .. } => (500..600).contains(status),
197            Self::AccessDenied
198            | Self::Expired
199            | Self::IncorrectDeviceCode
200            | Self::AppMisconfigured { .. }
201            | Self::Unexpected { .. }
202            | Self::UntrustedVerificationUri { .. }
203            | Self::Decode { .. }
204            | Self::Malformed { .. }
205            | Self::Config(_) => false,
206        }
207    }
208
209    /// Whether the remedy is a fresh `auth login` rather than a maintainer fix.
210    #[must_use]
211    pub fn requires_new_login(&self) -> bool {
212        matches!(self, Self::Expired | Self::IncorrectDeviceCode)
213    }
214}
215
216fn transport(err: reqwest::Error) -> DeviceFlowError {
217    DeviceFlowError::Transport(err.without_url())
218}
219
220// ---------------------------------------------------------------------------
221// The authorization
222// ---------------------------------------------------------------------------
223
224/// What [`DeviceFlow::start`] returns: everything the login needs, with the one
225/// secret in it wrapped.
226///
227/// `07-security.md`'s credential inventory splits the two codes deliberately:
228/// "the user code is shown on screen by design, the device code never is". So
229/// [`DeviceAuthorization::user_code`] hands out a plain `&str` and
230/// [`DeviceAuthorization::device_code`] hands out a [`SecretString`], and
231/// `Debug` is written by hand so that neither a derive nor a future field can
232/// quietly change that.
233#[derive(Clone)]
234pub struct DeviceAuthorization {
235    device_code: SecretString,
236    user_code: String,
237    verification_uri: Url,
238    expires_in: Duration,
239    interval: Duration,
240}
241
242impl DeviceAuthorization {
243    /// The code the user types. Displayed by design, and only during login.
244    #[must_use]
245    pub fn user_code(&self) -> &str {
246        &self.user_code
247    }
248
249    /// The code this process proves possession of. Never displayed, never
250    /// logged, never persisted.
251    #[must_use]
252    pub fn device_code(&self) -> &SecretString {
253        &self.device_code
254    }
255
256    /// The page the user code is typed into — validated to be on GitHub's own
257    /// origin by [`DeviceFlow::start`].
258    #[must_use]
259    pub fn verification_uri(&self) -> &Url {
260        &self.verification_uri
261    }
262
263    /// How long the whole login has before the device code dies.
264    #[must_use]
265    pub fn expires_in(&self) -> Duration {
266        self.expires_in
267    }
268
269    /// The interval GitHub asked to be polled at, before any `slow_down`.
270    #[must_use]
271    pub fn interval(&self) -> Duration {
272        self.interval
273    }
274}
275
276impl fmt::Debug for DeviceAuthorization {
277    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278        f.debug_struct("DeviceAuthorization")
279            .field("device_code", &"[REDACTED]")
280            .field("user_code", &self.user_code)
281            .field("verification_uri", &self.verification_uri.as_str())
282            .field("expires_in_secs", &self.expires_in.as_secs())
283            .field("interval_secs", &self.interval.as_secs())
284            .finish()
285    }
286}
287
288/// The result of one poll.
289///
290/// The two recoverable states of the error matrix live here rather than in
291/// [`DeviceFlowError`], which is what stops a caller from treating "the user has
292/// not clicked approve yet" as a failure.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum PollOutcome {
295    /// `authorization_pending` — keep polling at the current interval.
296    Pending,
297    /// `slow_down` — keep polling, at this longer interval from now on.
298    SlowDown { interval: Duration },
299    /// The user approved. This is the only value in the crate that carries a
300    /// live credential out of the device flow.
301    Approved(UserAccessToken),
302}
303
304// ---------------------------------------------------------------------------
305// The flow
306// ---------------------------------------------------------------------------
307
308/// The device-flow client.
309///
310/// Holds no credential of its own — only the public `client_id` — which is what
311/// makes it constructible before any login has ever happened.
312#[derive(Debug, Clone)]
313pub struct DeviceFlow {
314    http: reqwest::Client,
315    app: AppRegistration,
316    endpoints: Endpoints,
317}
318
319impl DeviceFlow {
320    /// # Errors
321    /// The HTTP client failing to build.
322    pub fn new(app: AppRegistration, endpoints: Endpoints) -> Result<Self, DeviceFlowError> {
323        let http = reqwest::Client::builder()
324            .timeout(DEFAULT_REQUEST_TIMEOUT)
325            .build()
326            .map_err(transport)?;
327        Ok(Self::with_http_client(http, app, endpoints))
328    }
329
330    /// Exchange a refresh token for a fresh pair.
331    ///
332    /// # No client secret, and why that is not an oversight
333    ///
334    /// GitHub requires a confidential client credential for this exchange
335    /// *"unless the user access token was generated using the device flow"*,
336    /// and this product's tokens always are. That exemption is what makes
337    /// renewal possible here at all: a published binary cannot carry such a
338    /// credential, because anyone who downloads it has it -- and with it can
339    /// call the App's token-management endpoints and revoke every user's
340    /// access. Verified against live GitHub before this was written: the call
341    /// below returns `200` with `client_id` alone.
342    ///
343    /// # The old pair is dead the instant this succeeds
344    ///
345    /// GitHub rotates: *"Once you use a refresh token, that refresh token and
346    /// the old user access token will no longer work."* Measured, not assumed
347    /// -- the previous access token answered `401` immediately after.
348    ///
349    /// So there is no retry here and there must not be one. A caller that
350    /// re-sends a spent refresh token gets `incorrect_client_credentials`,
351    /// which names the client id and the client secret and is about neither:
352    /// the credential is simply gone, and the machine needs an interactive
353    /// sign-in. **Persist what this returns before using it**, or a response
354    /// lost in flight takes the host's access with it.
355    ///
356    /// # Errors
357    /// [`DeviceFlowError`], as the access-token request.
358    pub async fn refresh(
359        &self,
360        refresh_token: &SecretString,
361    ) -> Result<UserAccessToken, DeviceFlowError> {
362        // In the body for the same reason the device code is: a refresh token
363        // in a query string is written to every proxy log it passes.
364        let body = form_body(&[
365            ("client_id", self.app.client_id()),
366            ("refresh_token", refresh_token.expose_secret()),
367            ("grant_type", "refresh_token"),
368        ]);
369
370        let response = self
371            .http
372            .post(self.endpoints.access_token_url())
373            .header(reqwest::header::ACCEPT, "application/json")
374            .header(reqwest::header::USER_AGENT, USER_AGENT)
375            .header(
376                reqwest::header::CONTENT_TYPE,
377                "application/x-www-form-urlencoded",
378            )
379            .body(body)
380            .send()
381            .await
382            .map_err(transport)?;
383
384        let status = response.status();
385        let bytes = response.bytes().await.map_err(transport)?;
386        let raw: RawTokenResponse = match serde_json::from_slice(&bytes) {
387            Ok(raw) => raw,
388            Err(source) => {
389                return if status.is_success() {
390                    Err(DeviceFlowError::Decode {
391                        stage: "refresh",
392                        source,
393                    })
394                } else {
395                    Err(DeviceFlowError::Status {
396                        status: status.as_u16(),
397                        stage: "refresh request",
398                    })
399                };
400            }
401        };
402
403        if let Some(code) = raw.error {
404            return Err(DeviceFlowError::Malformed {
405                what: "a refresh response",
406                // The code, not GitHub's sentence: `incorrect_client_credentials`
407                // arrives here for a spent refresh token and its description
408                // blames the client id and secret, which would send an operator
409                // to re-register an App that is perfectly fine.
410                value: code,
411            });
412        }
413        let Some(access_token) = raw.access_token else {
414            return Err(DeviceFlowError::Malformed {
415                what: "a refresh response",
416                value: "neither an access token nor an error".to_string(),
417            });
418        };
419        Ok(UserAccessToken::from_parts(
420            SecretString::from(access_token),
421            raw.token_type.unwrap_or_else(|| "bearer".to_string()),
422            raw.scope.filter(|s| !s.is_empty()),
423        )
424        .with_renewal(
425            raw.refresh_token.map(SecretString::from),
426            raw.expires_in,
427            raw.refresh_token_expires_in,
428        ))
429    }
430
431    #[must_use]
432    pub fn with_http_client(
433        http: reqwest::Client,
434        app: AppRegistration,
435        endpoints: Endpoints,
436    ) -> Self {
437        Self {
438            http,
439            app,
440            endpoints,
441        }
442    }
443
444    /// The canonical page this login must be approved on, and the only
445    /// device-flow URL the product ever prints.
446    #[must_use]
447    pub fn verification_url(&self) -> Url {
448        self.endpoints.verification_url()
449    }
450
451    /// Begin a login.
452    ///
453    /// The request carries the public `client_id` and nothing else — no secret,
454    /// no scope (a GitHub App's scopes come from its declared permissions, not
455    /// from the grant), and no redirect URI.
456    ///
457    /// # Errors
458    /// [`DeviceFlowError::Transport`], [`DeviceFlowError::Status`],
459    /// [`DeviceFlowError::Decode`], [`DeviceFlowError::Malformed`], or
460    /// [`DeviceFlowError::UntrustedVerificationUri`].
461    pub async fn start(&self) -> Result<DeviceAuthorization, DeviceFlowError> {
462        let body = form_body(&[("client_id", self.app.client_id())]);
463        let response = self
464            .http
465            .post(self.endpoints.device_code_url())
466            .header(reqwest::header::ACCEPT, "application/json")
467            .header(reqwest::header::USER_AGENT, USER_AGENT)
468            .header(
469                reqwest::header::CONTENT_TYPE,
470                "application/x-www-form-urlencoded",
471            )
472            .body(body)
473            .send()
474            .await
475            .map_err(transport)?;
476
477        let status = response.status();
478        let bytes = response.bytes().await.map_err(transport)?;
479        if !status.is_success() {
480            return Err(DeviceFlowError::Status {
481                status: status.as_u16(),
482                stage: "device code request",
483            });
484        }
485
486        let raw: RawDeviceCode =
487            serde_json::from_slice(&bytes).map_err(|source| DeviceFlowError::Decode {
488                stage: "device code",
489                source,
490            })?;
491
492        let verification_uri =
493            Url::parse(&raw.verification_uri).map_err(|_| DeviceFlowError::Malformed {
494                what: "a verification URL",
495                value: raw.verification_uri.clone(),
496            })?;
497        // The phishing control, enforced rather than documented: the code is only
498        // ever typed on GitHub's own origin.
499        if verification_uri.origin() != self.endpoints.web_base().origin() {
500            return Err(DeviceFlowError::UntrustedVerificationUri {
501                origin: verification_uri.origin().ascii_serialization(),
502            });
503        }
504
505        let authorization = DeviceAuthorization {
506            device_code: SecretString::from(raw.device_code),
507            user_code: raw.user_code,
508            verification_uri,
509            expires_in: Duration::from_secs(raw.expires_in.unwrap_or(900)),
510            interval: raw
511                .interval
512                .map_or(DEFAULT_POLL_INTERVAL, Duration::from_secs),
513        };
514
515        // The user code is displayed by design and is the one thing a caller
516        // must surface; the device code is not in this event and never will be.
517        tracing::info!(
518            user_code = %authorization.user_code,
519            verification_url = %self.verification_url(),
520            expires_in_secs = authorization.expires_in.as_secs(),
521            "device login started; approve it on GitHub's own device page"
522        );
523
524        Ok(authorization)
525    }
526
527    /// Ask once whether the login has been approved.
528    ///
529    /// # Errors
530    /// The four terminal members of the error matrix, plus transport and decode
531    /// failures. `authorization_pending` and `slow_down` are **not** errors —
532    /// they are [`PollOutcome`]s.
533    pub async fn poll_once(
534        &self,
535        authorization: &DeviceAuthorization,
536    ) -> Result<PollOutcome, DeviceFlowError> {
537        self.poll_once_from(authorization, authorization.interval)
538            .await
539    }
540
541    async fn poll_once_from(
542        &self,
543        authorization: &DeviceAuthorization,
544        current_interval: Duration,
545    ) -> Result<PollOutcome, DeviceFlowError> {
546        // The device code goes in the request *body*, never in the URL: a query
547        // string is logged by every proxy and appears in every access log, and
548        // `07-security.md` requires the device code to stay out of all of them.
549        let body = form_body(&[
550            ("client_id", self.app.client_id()),
551            ("device_code", authorization.device_code.expose_secret()),
552            ("grant_type", DEVICE_GRANT_TYPE),
553        ]);
554
555        let response = self
556            .http
557            .post(self.endpoints.access_token_url())
558            .header(reqwest::header::ACCEPT, "application/json")
559            .header(reqwest::header::USER_AGENT, USER_AGENT)
560            .header(
561                reqwest::header::CONTENT_TYPE,
562                "application/x-www-form-urlencoded",
563            )
564            .body(body)
565            .send()
566            .await
567            .map_err(transport)?;
568
569        let status = response.status();
570        let bytes = response.bytes().await.map_err(transport)?;
571        let mut raw: RawTokenResponse = match serde_json::from_slice(&bytes) {
572            Ok(raw) => raw,
573            // A body that is not the protocol's JSON at all — a proxy's HTML
574            // error page, say — is the *status*'s story, not a decode failure.
575            // Reporting a `502` as `Decode` hides the one fact that mattered
576            // behind a parser message, and makes a retryable gateway hiccup look
577            // like a terminal protocol violation. The status is only consulted
578            // here, once the body has already failed to be the protocol.
579            Err(source) => {
580                if status.is_success() {
581                    return Err(DeviceFlowError::Decode {
582                        stage: "access token",
583                        source,
584                    });
585                }
586                return Err(DeviceFlowError::Status {
587                    status: status.as_u16(),
588                    stage: "access token request",
589                });
590            }
591        };
592
593        // GitHub answers the pending and slow-down states with HTTP 200 and an
594        // `error` field, so the body is authoritative and the status is only a
595        // backstop for something outside the protocol entirely.
596        if let Some(code) = raw.error.take() {
597            return self.interpret_error(&code, raw.interval, current_interval);
598        }
599        if !status.is_success() {
600            return Err(DeviceFlowError::Status {
601                status: status.as_u16(),
602                stage: "access token request",
603            });
604        }
605
606        let Some(access_token) = raw.access_token.take() else {
607            return Err(DeviceFlowError::Malformed {
608                what: "an access token response",
609                value: "neither an access token nor an error".to_string(),
610            });
611        };
612
613        let token = UserAccessToken::from_parts(
614            SecretString::from(access_token),
615            raw.token_type.unwrap_or_else(|| "bearer".to_string()),
616            raw.scope.filter(|s| !s.is_empty()),
617        )
618        .with_renewal(
619            raw.refresh_token.map(SecretString::from),
620            raw.expires_in,
621            raw.refresh_token_expires_in,
622        );
623
624        // The family prefix, and nothing more. The D17 spike asserted exactly
625        // this to prove it had an App user-to-server token rather than an OAuth
626        // one; it is diagnostic, not secret.
627        tracing::info!(
628            token_family = token.family(),
629            user_to_server = token.is_user_to_server(),
630            "device login approved; the user access token was returned to the caller"
631        );
632
633        Ok(PollOutcome::Approved(token))
634    }
635
636    fn interpret_error(
637        &self,
638        code: &str,
639        advertised_interval: Option<u64>,
640        current_interval: Duration,
641    ) -> Result<PollOutcome, DeviceFlowError> {
642        match code {
643            "authorization_pending" => Ok(PollOutcome::Pending),
644            "slow_down" => {
645                let interval = slowed(
646                    current_interval,
647                    advertised_interval.map(Duration::from_secs),
648                );
649                tracing::debug!(
650                    from_secs = current_interval.as_secs(),
651                    to_secs = interval.as_secs(),
652                    "GitHub asked us to slow down; lengthening the poll interval"
653                );
654                Ok(PollOutcome::SlowDown { interval })
655            }
656            "expired_token" => Err(DeviceFlowError::Expired),
657            "access_denied" => Err(DeviceFlowError::AccessDenied),
658            "incorrect_device_code" => Err(DeviceFlowError::IncorrectDeviceCode),
659            "unsupported_grant_type" | "incorrect_client_credentials" | "device_flow_disabled" => {
660                Err(DeviceFlowError::AppMisconfigured {
661                    code: code.to_string(),
662                })
663            }
664            other => Err(DeviceFlowError::Unexpected {
665                code: other.to_string(),
666            }),
667        }
668    }
669
670    /// Poll until the login is approved, refused, or expires.
671    ///
672    /// Waiting goes through [`Sleeper`] rather than `tokio::time::sleep`, so a
673    /// test can assert on the *sequence of intervals* this produces instead of
674    /// waiting them out. That is what makes "`slow_down` demonstrably increases
675    /// the poll interval" an equality assertion rather than a stopwatch reading.
676    ///
677    /// The elapsed budget is accumulated from the intervals actually waited, so
678    /// the local expiry backstop is as deterministic as the rest. GitHub's own
679    /// `expired_token` remains authoritative and is checked first every round;
680    /// this only catches a server that never sends it.
681    ///
682    /// # One dropped packet does not kill a login
683    ///
684    /// A failure that says nothing about the login —
685    /// [`DeviceFlowError::is_retryable`], meaning a transport error or a `5xx` —
686    /// is absorbed here rather than propagated, up to
687    /// [`MAX_TRANSPORT_RETRIES`] consecutive times. This loop is the right place
688    /// for it and the caller is not: the poll interval, the expiry budget and
689    /// the device code all live here, so a retry costs one more scheduled poll
690    /// and nothing else, while a caller retrying `complete` would restart the
691    /// budget and re-derive the back-off. The counter resets on every successful
692    /// poll, so it bounds a *burst*, not the whole login.
693    ///
694    /// The four terminal states of the error matrix are unaffected: they are not
695    /// retryable, and a login the user declined is still refused on the first
696    /// answer.
697    ///
698    /// # Errors
699    /// Every terminal member of the error matrix.
700    pub async fn complete(
701        &self,
702        authorization: &DeviceAuthorization,
703        sleeper: &dyn Sleeper,
704    ) -> Result<UserAccessToken, DeviceFlowError> {
705        let mut interval = authorization.interval;
706        let mut elapsed = Duration::ZERO;
707        let mut consecutive_retryable = 0_u32;
708
709        loop {
710            // Wait first: the user has to read the code, open the page, and type
711            // it. Polling before that has elapsed only spends rate limit.
712            sleeper.sleep(interval).await;
713            elapsed = elapsed.saturating_add(interval);
714
715            let outcome = match self.poll_once_from(authorization, interval).await {
716                Ok(outcome) => {
717                    consecutive_retryable = 0;
718                    outcome
719                }
720                Err(err) if err.is_retryable() && consecutive_retryable < MAX_TRANSPORT_RETRIES => {
721                    consecutive_retryable += 1;
722                    tracing::warn!(
723                        attempt = consecutive_retryable,
724                        max_attempts = MAX_TRANSPORT_RETRIES,
725                        error = %err,
726                        "a device-flow poll failed in a way that says nothing about the login; \
727                         the device code is still live, so polling continues"
728                    );
729                    // Indistinguishable from "not approved yet" as far as this
730                    // loop is concerned: wait the interval and ask again.
731                    PollOutcome::Pending
732                }
733                Err(err) => return Err(err),
734            };
735
736            match outcome {
737                PollOutcome::Approved(token) => return Ok(token),
738                PollOutcome::SlowDown { interval: next } => interval = next,
739                PollOutcome::Pending => {}
740            }
741
742            if elapsed >= authorization.expires_in {
743                tracing::warn!(
744                    waited_secs = elapsed.as_secs(),
745                    expires_in_secs = authorization.expires_in.as_secs(),
746                    "the device code's own lifetime elapsed before approval"
747                );
748                return Err(DeviceFlowError::Expired);
749            }
750        }
751    }
752}
753
754/// The new interval after a `slow_down`.
755///
756/// Takes whichever is larger of GitHub's advertised interval and RFC 8628's
757/// mandatory `current + 5s` floor. In practice GitHub advertises exactly the
758/// floor, so the two agree; taking the maximum means a response that omits the
759/// field, or under-states it, still lengthens the interval rather than leaving
760/// it unchanged. An interval that failed to grow would poll GitHub at the rate
761/// it just asked us to stop polling at.
762fn slowed(current: Duration, advertised: Option<Duration>) -> Duration {
763    let floor = current.saturating_add(SLOW_DOWN_INCREMENT);
764    advertised.map_or(floor, |advertised| advertised.max(floor))
765}
766
767/// `application/x-www-form-urlencoded`, built with `url`'s own serializer.
768///
769/// `reqwest`'s `.form()` would do this, but it is behind the `form` feature and
770/// the workspace does not enable it — and `a1` owns every manifest, so needing a
771/// feature would be a reason to stop rather than to edit one. `url` is already a
772/// dependency and re-exports `form_urlencoded`, so this costs nothing and
773/// produces the identical wire format: the one both spikes ran against.
774fn form_body(pairs: &[(&str, &str)]) -> String {
775    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
776    for (key, value) in pairs {
777        serializer.append_pair(key, value);
778    }
779    serializer.finish()
780}
781
782// ---------------------------------------------------------------------------
783// Wire shapes
784// ---------------------------------------------------------------------------
785
786#[derive(Debug, Deserialize)]
787struct RawDeviceCode {
788    device_code: String,
789    user_code: String,
790    verification_uri: String,
791    #[serde(default)]
792    expires_in: Option<u64>,
793    #[serde(default)]
794    interval: Option<u64>,
795}
796
797/// Deliberately **not** `Debug`-derived anywhere it could be logged: it holds
798/// the raw token before it reaches [`SecretString`]. It is consumed inside
799/// [`DeviceFlow::poll_once_from`] and never leaves it.
800#[derive(Deserialize)]
801struct RawTokenResponse {
802    #[serde(default)]
803    access_token: Option<String>,
804    #[serde(default)]
805    token_type: Option<String>,
806    #[serde(default)]
807    scope: Option<String>,
808    /// Present only when the App has user-token expiration enabled.
809    ///
810    /// An App with expiration off returns neither this nor `expires_in`, and
811    /// the credential that results is the non-expiring one this product has
812    /// always held. Both shapes are accepted so that turning the setting on --
813    /// or off -- is a decision about the App rather than a release of this
814    /// binary.
815    #[serde(default)]
816    refresh_token: Option<String>,
817    /// Seconds until the access token expires. Always 28800 (8h) today.
818    #[serde(default)]
819    expires_in: Option<u64>,
820    /// Seconds until the refresh token expires. Always 15897600 (6mo) today.
821    #[serde(default)]
822    refresh_token_expires_in: Option<u64>,
823    #[serde(default)]
824    error: Option<String>,
825    /// Present on `slow_down`; the interval GitHub wants from now on.
826    #[serde(default)]
827    interval: Option<u64>,
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use crate::testing::{
834        FIXTURE_DEVICE_CODE, FIXTURE_TOKEN, FIXTURE_USER_CODE, RecordingSleeper, Script,
835        device_code_body, error_body, token_body,
836    };
837    use serde_json::json;
838    use wiremock::{
839        Mock, MockServer, ResponseTemplate,
840        matchers::{body_string_contains, header, method, path},
841    };
842
843    fn app() -> AppRegistration {
844        AppRegistration::new("Iv23liTESTCLIENTID", "runner-manager").unwrap()
845    }
846
847    fn flow(server: &MockServer) -> DeviceFlow {
848        DeviceFlow::new(app(), Endpoints::for_test_server(&server.uri()).unwrap()).unwrap()
849    }
850
851    async fn mount_start(server: &MockServer) {
852        Mock::given(method("POST"))
853            .and(path("/login/device/code"))
854            .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
855                &server.uri(),
856                5,
857                900,
858            )))
859            .mount(server)
860            .await;
861    }
862
863    async fn mount_token(server: &MockServer, responses: Vec<ResponseTemplate>) {
864        Mock::given(method("POST"))
865            .and(path("/login/oauth/access_token"))
866            .respond_with(Script::new(responses))
867            .mount(server)
868            .await;
869    }
870
871    // -- the happy path -----------------------------------------------------
872
873    #[tokio::test]
874    async fn a_device_flow_round_trip_against_fixtures_succeeds() {
875        let server = MockServer::start().await;
876        mount_start(&server).await;
877        mount_token(
878            &server,
879            vec![ResponseTemplate::new(200).set_body_json(token_body())],
880        )
881        .await;
882
883        let flow = flow(&server);
884        let authorization = flow.start().await.expect("device code");
885        assert_eq!(authorization.user_code(), FIXTURE_USER_CODE);
886        assert_eq!(
887            authorization.device_code().expose_secret(),
888            FIXTURE_DEVICE_CODE
889        );
890        assert_eq!(authorization.interval(), Duration::from_secs(5));
891        assert_eq!(authorization.expires_in(), Duration::from_secs(900));
892
893        let sleeper = RecordingSleeper::default();
894        let token = flow
895            .complete(&authorization, &sleeper)
896            .await
897            .expect("approved");
898
899        assert_eq!(token.secret().expose_secret(), FIXTURE_TOKEN);
900        assert_eq!(token.token_type(), "bearer");
901        assert_eq!(token.family(), "ghu_");
902        assert!(
903            token.is_user_to_server(),
904            "the published App issues user-to-server tokens; anything else means the \
905             registration is not the one this product authenticates as"
906        );
907        assert_eq!(sleeper.recorded(), vec![Duration::from_secs(5)]);
908    }
909
910    #[tokio::test]
911    async fn the_start_request_carries_the_public_client_id_and_no_secret() {
912        let server = MockServer::start().await;
913        Mock::given(method("POST"))
914            .and(path("/login/device/code"))
915            .and(header("accept", "application/json"))
916            .and(header("content-type", "application/x-www-form-urlencoded"))
917            .and(body_string_contains("client_id=Iv23liTESTCLIENTID"))
918            .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
919                &server.uri(),
920                5,
921                900,
922            )))
923            .expect(1)
924            .mount(&server)
925            .await;
926
927        flow(&server).start().await.expect("device code");
928
929        let sent = server.received_requests().await.unwrap();
930        let body = String::from_utf8(sent[0].body.clone()).unwrap();
931        assert_eq!(
932            body, "client_id=Iv23liTESTCLIENTID",
933            "the start request is the client id and nothing else: no secret, no scope, \
934             no redirect URI"
935        );
936    }
937
938    #[tokio::test]
939    async fn the_device_code_travels_in_the_body_and_never_in_the_url() {
940        let server = MockServer::start().await;
941        mount_start(&server).await;
942        mount_token(
943            &server,
944            vec![ResponseTemplate::new(200).set_body_json(token_body())],
945        )
946        .await;
947
948        let flow = flow(&server);
949        let authorization = flow.start().await.unwrap();
950        flow.complete(&authorization, &RecordingSleeper::default())
951            .await
952            .unwrap();
953
954        let sent = server.received_requests().await.unwrap();
955        let poll = sent
956            .iter()
957            .find(|r| r.url.path() == "/login/oauth/access_token")
958            .expect("the poll happened");
959        assert!(
960            !poll.url.as_str().contains(FIXTURE_DEVICE_CODE),
961            "a query string reaches every proxy and access log: {}",
962            poll.url
963        );
964        let body = String::from_utf8(poll.body.clone()).unwrap();
965        assert!(body.contains(&format!("device_code={FIXTURE_DEVICE_CODE}")));
966        assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"));
967    }
968
969    // -- the four documented errors, each its own outcome -------------------
970
971    #[tokio::test]
972    async fn authorization_pending_keeps_polling_at_the_unchanged_interval() {
973        let server = MockServer::start().await;
974        mount_start(&server).await;
975        mount_token(
976            &server,
977            vec![
978                // One for the standalone `poll_once` below, then three for the
979                // `complete` loop.
980                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
981                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
982                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
983                ResponseTemplate::new(200).set_body_json(token_body()),
984            ],
985        )
986        .await;
987
988        let flow = flow(&server);
989        let authorization = flow.start().await.unwrap();
990
991        assert_eq!(
992            flow.poll_once(&authorization).await.unwrap(),
993            PollOutcome::Pending,
994            "pending is an outcome, not a failure"
995        );
996
997        let sleeper = RecordingSleeper::default();
998        flow.complete(&authorization, &sleeper).await.unwrap();
999        assert_eq!(
1000            sleeper.recorded(),
1001            vec![
1002                Duration::from_secs(5),
1003                Duration::from_secs(5),
1004                Duration::from_secs(5)
1005            ],
1006            "authorization_pending must not change the interval"
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn slow_down_demonstrably_increases_the_poll_interval() {
1012        let server = MockServer::start().await;
1013        mount_start(&server).await;
1014        mount_token(
1015            &server,
1016            vec![
1017                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1018                ResponseTemplate::new(200).set_body_json(error_body("slow_down", Some(10))),
1019                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1020                ResponseTemplate::new(200).set_body_json(error_body("slow_down", Some(15))),
1021                ResponseTemplate::new(200).set_body_json(token_body()),
1022            ],
1023        )
1024        .await;
1025
1026        let flow = flow(&server);
1027        let authorization = flow.start().await.unwrap();
1028        let sleeper = RecordingSleeper::default();
1029        flow.complete(&authorization, &sleeper).await.unwrap();
1030
1031        let waited = sleeper.recorded();
1032        assert_eq!(
1033            waited,
1034            vec![
1035                Duration::from_secs(5),  // the advertised interval
1036                Duration::from_secs(5), // still 5: the first slow_down is the *answer* to this poll
1037                Duration::from_secs(10), // now longer
1038                Duration::from_secs(10),
1039                Duration::from_secs(15), // longer again
1040            ],
1041            "every slow_down must lengthen the interval used from then on"
1042        );
1043
1044        // Stated as an invariant as well as a fixture, so the assertion is about
1045        // the behaviour and not about this particular script.
1046        let mut increases = 0;
1047        for pair in waited.windows(2) {
1048            assert!(
1049                pair[1] >= pair[0],
1050                "the interval must never shrink: {pair:?}"
1051            );
1052            if pair[1] > pair[0] {
1053                increases += 1;
1054            }
1055        }
1056        assert_eq!(increases, 2, "two slow_downs, two increases");
1057    }
1058
1059    #[tokio::test]
1060    async fn a_slow_down_without_an_advertised_interval_still_adds_five_seconds() {
1061        let server = MockServer::start().await;
1062        mount_start(&server).await;
1063        mount_token(
1064            &server,
1065            vec![
1066                ResponseTemplate::new(200).set_body_json(error_body("slow_down", None)),
1067                ResponseTemplate::new(200).set_body_json(token_body()),
1068            ],
1069        )
1070        .await;
1071
1072        let flow = flow(&server);
1073        let authorization = flow.start().await.unwrap();
1074
1075        assert_eq!(
1076            flow.poll_once(&authorization).await.unwrap(),
1077            PollOutcome::SlowDown {
1078                interval: Duration::from_secs(10)
1079            },
1080            "RFC 8628 makes the +5s increase mandatory even with no interval in the body"
1081        );
1082    }
1083
1084    #[test]
1085    fn the_slow_down_interval_never_shrinks_whatever_the_server_advertises() {
1086        assert_eq!(
1087            slowed(Duration::from_secs(5), None),
1088            Duration::from_secs(10)
1089        );
1090        assert_eq!(
1091            slowed(Duration::from_secs(5), Some(Duration::from_secs(10))),
1092            Duration::from_secs(10),
1093            "GitHub advertises exactly the RFC floor, so the two agree"
1094        );
1095        assert_eq!(
1096            slowed(Duration::from_secs(5), Some(Duration::from_secs(30))),
1097            Duration::from_secs(30),
1098            "a server asking for more than the floor gets it"
1099        );
1100        assert_eq!(
1101            slowed(Duration::from_secs(5), Some(Duration::from_secs(1))),
1102            Duration::from_secs(10),
1103            "a server asking for LESS must not be able to speed us up: slow_down means slow down"
1104        );
1105    }
1106
1107    #[tokio::test]
1108    async fn expired_token_is_terminal_and_asks_for_a_whole_new_login() {
1109        let server = MockServer::start().await;
1110        mount_start(&server).await;
1111        mount_token(
1112            &server,
1113            vec![ResponseTemplate::new(200).set_body_json(error_body("expired_token", None))],
1114        )
1115        .await;
1116
1117        let flow = flow(&server);
1118        let authorization = flow.start().await.unwrap();
1119        let err = flow
1120            .complete(&authorization, &RecordingSleeper::default())
1121            .await
1122            .expect_err("expired");
1123
1124        assert!(matches!(err, DeviceFlowError::Expired), "{err:?}");
1125        assert!(!err.is_retryable());
1126        assert!(err.requires_new_login());
1127        assert!(err.to_string().contains("auth login"));
1128    }
1129
1130    #[tokio::test]
1131    async fn access_denied_is_terminal_and_is_not_an_error_to_retry() {
1132        let server = MockServer::start().await;
1133        mount_start(&server).await;
1134        mount_token(
1135            &server,
1136            vec![ResponseTemplate::new(200).set_body_json(error_body("access_denied", None))],
1137        )
1138        .await;
1139
1140        let flow = flow(&server);
1141        let authorization = flow.start().await.unwrap();
1142        let err = flow
1143            .complete(&authorization, &RecordingSleeper::default())
1144            .await
1145            .expect_err("declined");
1146
1147        assert!(matches!(err, DeviceFlowError::AccessDenied), "{err:?}");
1148        assert!(!err.is_retryable(), "the user said no; do not ask again");
1149        assert!(
1150            !err.requires_new_login(),
1151            "a refusal is not an expiry: `auth login` again is the operator's choice, \
1152             not this error's instruction"
1153        );
1154    }
1155
1156    // -- one dropped packet must not kill a login ---------------------------
1157
1158    /// `is_retryable()` was unconditionally `false`, and `complete()` propagates
1159    /// with `?`, so a single transient failure anywhere in a fifteen-minute poll
1160    /// aborted the whole login and made the user approve again — on the
1161    /// product's only authentication path.
1162    #[tokio::test]
1163    async fn a_gateway_blip_mid_poll_does_not_abort_the_login() {
1164        let server = MockServer::start().await;
1165        mount_start(&server).await;
1166        mount_token(
1167            &server,
1168            vec![
1169                ResponseTemplate::new(502).set_body_string("<html>Bad Gateway</html>"),
1170                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1171                ResponseTemplate::new(503).set_body_string("<html>Service Unavailable</html>"),
1172                ResponseTemplate::new(200).set_body_json(token_body()),
1173            ],
1174        )
1175        .await;
1176
1177        let flow = flow(&server);
1178        let authorization = flow.start().await.unwrap();
1179        let sleeper = RecordingSleeper::default();
1180        let token = flow
1181            .complete(&authorization, &sleeper)
1182            .await
1183            .expect("two blips must not destroy a login the user is about to approve");
1184
1185        assert_eq!(token.secret().expose_secret(), FIXTURE_TOKEN);
1186        assert_eq!(
1187            sleeper.recorded().len(),
1188            4,
1189            "each absorbed failure costs one more scheduled poll and nothing else"
1190        );
1191    }
1192
1193    /// The retry is bounded: a GitHub that is genuinely down is reported, not
1194    /// polled at for the whole expiry window.
1195    #[tokio::test]
1196    async fn the_transport_retry_is_bounded_rather_than_endless() {
1197        let server = MockServer::start().await;
1198        mount_start(&server).await;
1199        mount_token(
1200            &server,
1201            vec![ResponseTemplate::new(503).set_body_string("down")],
1202        )
1203        .await;
1204
1205        let flow = flow(&server);
1206        let authorization = flow.start().await.unwrap();
1207        let sleeper = RecordingSleeper::default();
1208        let err = flow
1209            .complete(&authorization, &sleeper)
1210            .await
1211            .expect_err("a persistently unreachable GitHub is still a failure");
1212
1213        assert!(
1214            matches!(err, DeviceFlowError::Status { status: 503, .. }),
1215            "{err:?}"
1216        );
1217        assert_eq!(
1218            sleeper.recorded().len(),
1219            MAX_TRANSPORT_RETRIES as usize + 1,
1220            "one initial poll plus exactly {MAX_TRANSPORT_RETRIES} absorbed retries"
1221        );
1222    }
1223
1224    /// A real transport failure — nothing listening at all — takes the same
1225    /// path. The bound is what stops this from polling until the device code
1226    /// expires.
1227    #[tokio::test]
1228    async fn a_transport_failure_is_absorbed_and_bounded_like_a_gateway_error() {
1229        let server = MockServer::start().await;
1230        mount_start(&server).await;
1231        let flow = flow(&server);
1232        let authorization = flow.start().await.unwrap();
1233
1234        // A port the OS handed out and then took back: nothing is listening, so
1235        // every poll is a connection failure rather than an HTTP answer. Bound
1236        // to port 0 so this can never collide with a parallel worker's server.
1237        let dead_port = {
1238            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("an ephemeral port");
1239            listener.local_addr().expect("a bound address").port()
1240        };
1241        let unreachable = DeviceFlow::new(
1242            app(),
1243            Endpoints::for_test_server(&format!("http://127.0.0.1:{dead_port}")).unwrap(),
1244        )
1245        .unwrap();
1246
1247        let sleeper = RecordingSleeper::default();
1248        let err = unreachable
1249            .complete(&authorization, &sleeper)
1250            .await
1251            .expect_err("nothing is listening");
1252
1253        assert!(matches!(err, DeviceFlowError::Transport(_)), "{err:?}");
1254        assert!(
1255            err.is_retryable(),
1256            "the device code is still live, so `f1` may present this same login again"
1257        );
1258        assert_eq!(
1259            sleeper.recorded().len(),
1260            MAX_TRANSPORT_RETRIES as usize + 1,
1261            "a dropped connection is absorbed like any other blip, and bounded the same way"
1262        );
1263    }
1264
1265    /// The body was decoded before the status was consulted, so a proxy's HTML
1266    /// error page surfaced as a `Decode` failure — terminal-looking, and
1267    /// silent about the one fact that mattered.
1268    #[tokio::test]
1269    async fn a_proxy_error_page_is_reported_as_its_status_not_as_a_decode_failure() {
1270        let server = MockServer::start().await;
1271        mount_start(&server).await;
1272        mount_token(
1273            &server,
1274            vec![
1275                ResponseTemplate::new(502)
1276                    .insert_header("content-type", "text/html")
1277                    .set_body_string("<html><body>502 Bad Gateway</body></html>"),
1278            ],
1279        )
1280        .await;
1281
1282        let flow = flow(&server);
1283        let authorization = flow.start().await.unwrap();
1284        let err = flow
1285            .poll_once(&authorization)
1286            .await
1287            .expect_err("a gateway page is not a token response");
1288
1289        match err {
1290            DeviceFlowError::Status { status, stage } => {
1291                assert_eq!(status, 502);
1292                assert_eq!(stage, "access token request");
1293            }
1294            other => panic!("expected the status, got {other:?}"),
1295        }
1296    }
1297
1298    /// The other half of that reorder: the status is consulted only *after* the
1299    /// body has failed to be the protocol, so a successful response carrying
1300    /// nonsense is still a decode failure and is still terminal.
1301    #[tokio::test]
1302    async fn a_200_that_is_not_the_protocol_is_still_a_decode_failure() {
1303        let server = MockServer::start().await;
1304        mount_start(&server).await;
1305        mount_token(
1306            &server,
1307            vec![ResponseTemplate::new(200).set_body_string("not json at all")],
1308        )
1309        .await;
1310
1311        let flow = flow(&server);
1312        let authorization = flow.start().await.unwrap();
1313        let err = flow.poll_once(&authorization).await.expect_err("garbage");
1314
1315        assert!(matches!(err, DeviceFlowError::Decode { .. }), "{err:?}");
1316        assert!(
1317            !err.is_retryable(),
1318            "a 200 that is not the protocol is a protocol violation, not a blip"
1319        );
1320    }
1321
1322    /// The retry must never reach a terminal state. A user who declined is not
1323    /// asked again, an expired code is not re-presented, and a misconfigured App
1324    /// is not polled until the device code dies.
1325    ///
1326    /// The last two codes are the ones this list was missing. It iterated only
1327    /// the three states that map to their own error variants, so
1328    /// [`DeviceFlowError::AppMisconfigured`] and [`DeviceFlowError::Unexpected`]
1329    /// — between them every remaining code GitHub can send — were covered by
1330    /// `is_retryable`'s `match` arm and by nothing that would notice if the arm
1331    /// moved. Making either retryable broke no test at all, and both would spin
1332    /// the poll loop against an App that a maintainer has to fix.
1333    #[tokio::test]
1334    async fn the_terminal_states_are_never_retried() {
1335        for code in [
1336            "access_denied",
1337            "expired_token",
1338            "incorrect_device_code",
1339            // `AppMisconfigured`: a maintainer fix, never a retry.
1340            "device_flow_disabled",
1341            // `Unexpected`: an unrecognised code is an answer this client
1342            // cannot interpret, which is not the same as a blip it can absorb.
1343            "a_code_this_client_has_never_heard_of",
1344        ] {
1345            let server = MockServer::start().await;
1346            mount_start(&server).await;
1347            mount_token(
1348                &server,
1349                vec![ResponseTemplate::new(200).set_body_json(error_body(code, None))],
1350            )
1351            .await;
1352
1353            let flow = flow(&server);
1354            let authorization = flow.start().await.unwrap();
1355            let sleeper = RecordingSleeper::default();
1356            let err = flow
1357                .complete(&authorization, &sleeper)
1358                .await
1359                .expect_err("terminal");
1360
1361            assert!(!err.is_retryable(), "{code} must stay terminal: {err:?}");
1362            assert_eq!(
1363                sleeper.recorded().len(),
1364                1,
1365                "{code} must be answered on the first poll and never polled again"
1366            );
1367        }
1368    }
1369
1370    /// One table, four codes, four distinct outcomes — which is the property the
1371    /// Definition of Done asks for, stated in one place so that collapsing any
1372    /// two of them fails here.
1373    #[tokio::test]
1374    async fn the_four_documented_errors_produce_four_distinct_outcomes() {
1375        let server = MockServer::start().await;
1376        mount_start(&server).await;
1377        let flow = flow(&server);
1378        let authorization = flow.start().await.unwrap();
1379
1380        let mut described = Vec::new();
1381        for code in [
1382            "authorization_pending",
1383            "slow_down",
1384            "expired_token",
1385            "access_denied",
1386        ] {
1387            let scoped = MockServer::start().await;
1388            Mock::given(method("POST"))
1389                .and(path("/login/oauth/access_token"))
1390                .respond_with(ResponseTemplate::new(200).set_body_json(error_body(code, None)))
1391                .mount(&scoped)
1392                .await;
1393            let scoped_flow =
1394                DeviceFlow::new(app(), Endpoints::for_test_server(&scoped.uri()).unwrap()).unwrap();
1395
1396            described.push(match scoped_flow.poll_once(&authorization).await {
1397                Ok(PollOutcome::Pending) => "pending".to_string(),
1398                Ok(PollOutcome::SlowDown { interval }) => {
1399                    format!("slow_down->{}s", interval.as_secs())
1400                }
1401                Ok(PollOutcome::Approved(_)) => "approved".to_string(),
1402                Err(err) => format!("error:{err:?}"),
1403            });
1404        }
1405
1406        assert_eq!(
1407            described,
1408            vec![
1409                "pending",
1410                "slow_down->10s",
1411                "error:Expired",
1412                "error:AccessDenied",
1413            ]
1414        );
1415        let mut unique = described.clone();
1416        unique.sort();
1417        unique.dedup();
1418        assert_eq!(unique.len(), 4, "four codes must not collapse into fewer");
1419    }
1420
1421    #[tokio::test]
1422    async fn an_unrecognised_error_is_reported_as_itself_rather_than_guessed_at() {
1423        let server = MockServer::start().await;
1424        mount_start(&server).await;
1425        mount_token(
1426            &server,
1427            vec![
1428                ResponseTemplate::new(200).set_body_json(error_body("device_flow_disabled", None)),
1429            ],
1430        )
1431        .await;
1432        let flow = flow(&server);
1433        let authorization = flow.start().await.unwrap();
1434
1435        let err = flow.poll_once(&authorization).await.expect_err("disabled");
1436        match err {
1437            DeviceFlowError::AppMisconfigured { code } => {
1438                assert_eq!(code, "device_flow_disabled");
1439            }
1440            other => panic!("expected a registration error, got {other:?}"),
1441        }
1442    }
1443
1444    #[tokio::test]
1445    async fn the_local_deadline_backstops_a_server_that_never_says_expired() {
1446        let server = MockServer::start().await;
1447        // A 20-second lifetime and a 5-second interval: four polls fit.
1448        Mock::given(method("POST"))
1449            .and(path("/login/device/code"))
1450            .respond_with(ResponseTemplate::new(200).set_body_json(device_code_body(
1451                &server.uri(),
1452                5,
1453                20,
1454            )))
1455            .mount(&server)
1456            .await;
1457        mount_token(
1458            &server,
1459            vec![
1460                ResponseTemplate::new(200).set_body_json(error_body("authorization_pending", None)),
1461            ],
1462        )
1463        .await;
1464
1465        let flow = flow(&server);
1466        let authorization = flow.start().await.unwrap();
1467        let sleeper = RecordingSleeper::default();
1468        let err = flow
1469            .complete(&authorization, &sleeper)
1470            .await
1471            .expect_err("the device code's own lifetime ran out");
1472
1473        assert!(matches!(err, DeviceFlowError::Expired), "{err:?}");
1474        assert_eq!(
1475            sleeper.recorded().len(),
1476            4,
1477            "the loop stops at the advertised lifetime rather than polling forever"
1478        );
1479    }
1480
1481    // -- phishing -----------------------------------------------------------
1482
1483    #[tokio::test]
1484    async fn a_verification_url_on_another_origin_is_refused_rather_than_displayed() {
1485        let server = MockServer::start().await;
1486        Mock::given(method("POST"))
1487            .and(path("/login/device/code"))
1488            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1489                "device_code": FIXTURE_DEVICE_CODE,
1490                "user_code": FIXTURE_USER_CODE,
1491                "verification_uri": "https://github.com.evil.example/login/device",
1492                "expires_in": 900,
1493                "interval": 5
1494            })))
1495            .mount(&server)
1496            .await;
1497
1498        let err = flow(&server).start().await.expect_err("wrong origin");
1499        match err {
1500            DeviceFlowError::UntrustedVerificationUri { origin } => {
1501                assert!(origin.contains("evil.example"), "{origin}");
1502            }
1503            other => panic!("expected the phishing control to fire, got {other:?}"),
1504        }
1505    }
1506
1507    #[test]
1508    fn the_printed_verification_url_is_the_compiled_in_canonical_one() {
1509        let production = DeviceFlow::new(app(), Endpoints::production()).unwrap();
1510        assert_eq!(
1511            production.verification_url().as_str(),
1512            "https://github.com/login/device",
1513            "the tool prints this and never proxies, embeds, or imitates the approval page"
1514        );
1515    }
1516
1517    // -- redaction ----------------------------------------------------------
1518
1519    #[tokio::test]
1520    async fn neither_code_nor_token_is_rendered_by_debug_or_display() {
1521        let server = MockServer::start().await;
1522        mount_start(&server).await;
1523        mount_token(
1524            &server,
1525            vec![ResponseTemplate::new(200).set_body_json(token_body())],
1526        )
1527        .await;
1528
1529        let flow = flow(&server);
1530        let authorization = flow.start().await.unwrap();
1531        let rendered = format!("{authorization:?}");
1532        assert!(
1533            !rendered.contains(FIXTURE_DEVICE_CODE),
1534            "the device code is never shown: {rendered}"
1535        );
1536        assert!(rendered.contains("[REDACTED]"));
1537        assert!(
1538            rendered.contains(FIXTURE_USER_CODE),
1539            "the user code is displayed by design, so it stays legible: {rendered}"
1540        );
1541
1542        let token = flow
1543            .complete(&authorization, &RecordingSleeper::default())
1544            .await
1545            .unwrap();
1546        assert!(!format!("{token:?}").contains(FIXTURE_TOKEN));
1547        assert!(!format!("{flow:?}").contains(FIXTURE_TOKEN));
1548
1549        // Every error's rendered text, too: an error message is a diagnostic and
1550        // `07-security.md` gates diagnostics as strictly as it gates logs.
1551        for err in [
1552            DeviceFlowError::AccessDenied,
1553            DeviceFlowError::Expired,
1554            DeviceFlowError::IncorrectDeviceCode,
1555            DeviceFlowError::AppMisconfigured {
1556                code: "device_flow_disabled".to_string(),
1557            },
1558            DeviceFlowError::Unexpected {
1559                code: "??".to_string(),
1560            },
1561        ] {
1562            let text = format!("{err} / {err:?}");
1563            assert!(!text.contains(FIXTURE_DEVICE_CODE), "{text}");
1564            assert!(!text.contains(FIXTURE_TOKEN), "{text}");
1565        }
1566    }
1567
1568    #[test]
1569    fn the_token_family_is_diagnostic_and_exposes_nothing_else() {
1570        let token = UserAccessToken::new(SecretString::from("ghu_abcdefghijklmnop"));
1571        assert_eq!(token.family(), "ghu_");
1572        assert!(token.is_user_to_server());
1573
1574        let oauth = UserAccessToken::new(SecretString::from("gho_abcdefghijklmnop"));
1575        assert_eq!(oauth.family(), "gho_");
1576        assert!(
1577            !oauth.is_user_to_server(),
1578            "a `gho_` token means this is not the published App's user-to-server credential"
1579        );
1580
1581        let odd = UserAccessToken::new(SecretString::from("no-underscore-here"));
1582        assert_eq!(
1583            odd.family(),
1584            "",
1585            "never guess, and never return a prefix of the token"
1586        );
1587    }
1588
1589    #[test]
1590    fn the_form_body_encodes_exactly_what_both_spikes_sent() {
1591        assert_eq!(
1592            form_body(&[("client_id", "Iv1"), ("grant_type", DEVICE_GRANT_TYPE)]),
1593            "client_id=Iv1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
1594        );
1595    }
1596}