Skip to main content

silicon_commit_client/
auth.rs

1//! Silicon Accounts sign-in for Commit's own tools.
2//!
3//! Commit's CLI (or any tool of Commit's that runs on someone's machine) is a *public
4//! client* at Silicon Accounts: it holds no app secret, only Commit's app id. A Carbon
5//! signs in with the device flow (approve a code on the account site); a Silicon hands
6//! over a short-lived token (`slt_…`) it minted with `silicon-accounts login --app commit -q`.
7//! Both return Commit's tokens: an access token (30 minutes, `aud = commit`) and a refresh
8//! token that rotates on every use. Presenting a used refresh token ends the whole sign-in,
9//! so refresh one at a time and store the new pair before using it.
10//!
11//! This module is stateless like the rest of the crate: callers decide where tokens live.
12
13use std::fmt;
14use std::time::Duration;
15
16use reqwest::StatusCode;
17use reqwest::header::{ACCEPT, HeaderMap};
18use secrecy::SecretString;
19use serde::de::DeserializeOwned;
20use serde::{Deserialize, Serialize};
21use silicon_accounts_client::{AccountsClient, DevicePoll, OAuthError, TokenResponse};
22
23pub use silicon_accounts_client::{AccountKind, DeviceAuthorization};
24
25use crate::error::{Error, root_cause};
26
27/// Commit's app id at Silicon Accounts.
28pub const APP_ID: &str = "commit";
29/// The production Silicon Accounts URL.
30pub const DEFAULT_ACCOUNTS_URL: &str = silicon_accounts_client::DEFAULT_BASE_URL;
31/// How many seconds RFC 8628 adds to the polling interval after `slow_down`.
32const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
33/// Largest answer read from Silicon Accounts by the direct calls.
34const MAX_BODY: usize = 1024 * 1024;
35
36/// Signs Carbons and Silicons in to Commit at Silicon Accounts, without an app secret.
37#[derive(Clone)]
38pub struct AccountsAuth {
39    accounts: AccountsClient,
40    http: reqwest::Client,
41    app_id: String,
42}
43
44impl fmt::Debug for AccountsAuth {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.debug_struct("AccountsAuth")
47            .field("accounts_url", &self.accounts.base_url().as_str())
48            .field("app_id", &self.app_id)
49            .finish_non_exhaustive()
50    }
51}
52
53/// Builder for [`AccountsAuth`].
54#[derive(Clone, Debug)]
55pub struct AccountsAuthBuilder {
56    url: String,
57    app_id: String,
58    product: Option<String>,
59}
60
61impl AccountsAuthBuilder {
62    /// The app id to sign in to (default `commit`; a development deployment may use another).
63    #[must_use]
64    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
65        self.app_id = app_id.into();
66        self
67    }
68
69    /// Does nothing. Every request already carries `X-Accounts-Telemetry: off`: Commit's
70    /// tools record no telemetry and ask Silicon Accounts not to record any for them.
71    #[deprecated(
72        since = "0.6.0",
73        note = "requests always opt out of Silicon Accounts telemetry; this does nothing and can be removed"
74    )]
75    #[must_use]
76    pub fn telemetry(self, _enabled: bool) -> Self {
77        self
78    }
79
80    /// A product token prepended to the User-Agent, e.g. `silicon-commit-cli/0.6.0`.
81    #[must_use]
82    pub fn user_agent(mut self, product: impl Into<String>) -> Self {
83        self.product = Some(product.into());
84        self
85    }
86
87    /// Builds the client. Plain `http://` is accepted only for this machine
88    /// (`localhost`, `127.0.0.1`, `::1`), so tokens never cross a network unencrypted.
89    pub fn build(self) -> Result<AccountsAuth, Error> {
90        let app_id = self.app_id.trim().to_owned();
91        if app_id.is_empty()
92            || app_id.len() > 64
93            || !app_id
94                .bytes()
95                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'))
96        {
97            return Err(Error::Invalid(format!(
98                "`{app_id}` is not an app id: use lowercase letters, digits, `-` and `_` (Commit's is `{APP_ID}`)."
99            )));
100        }
101        let product = self
102            .product
103            .unwrap_or_else(|| format!("silicon-commit-client/{}", env!("CARGO_PKG_VERSION")));
104        let accounts = AccountsClient::builder()
105            .base_url(self.url)
106            .telemetry(false)
107            .user_agent(product.clone())
108            .build()?;
109        let http = reqwest::Client::builder()
110            .redirect(reqwest::redirect::Policy::none())
111            .connect_timeout(Duration::from_secs(10))
112            .timeout(Duration::from_secs(30))
113            .user_agent(product)
114            .build()
115            .map_err(|e| {
116                Error::Invalid(format!(
117                    "Could not create the HTTP client: {}.",
118                    root_cause(&e)
119                ))
120            })?;
121        Ok(AccountsAuth {
122            accounts,
123            http,
124            app_id,
125        })
126    }
127}
128
129/// A finished sign-in: Commit's tokens and the account they belong to.
130#[derive(Clone)]
131#[non_exhaustive]
132pub struct SignIn {
133    /// Access token for the Commit API (`Authorization: Bearer …`).
134    pub access_token: SecretString,
135    /// Rotating refresh token (`sar_…`); store the newest one before using it.
136    pub refresh_token: Option<SecretString>,
137    /// Seconds until the access token expires, counted from when the answer arrived.
138    pub expires_in: u64,
139    /// When the sign-in ends however often it is refreshed (unix seconds), when known.
140    pub refresh_expires_at: Option<i64>,
141    /// Space-separated details the account shared with Commit (`profile`, `email`, …).
142    pub scope: Option<String>,
143    /// Who signed in.
144    pub account: SignedInAccount,
145}
146
147impl fmt::Debug for SignIn {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        f.debug_struct("SignIn")
150            .field("tokens", &"<redacted>")
151            .field("expires_in", &self.expires_in)
152            .field("refresh_expires_at", &self.refresh_expires_at)
153            .field("scope", &self.scope)
154            .field("account", &self.account)
155            .finish()
156    }
157}
158
159/// The account a sign-in belongs to, as Silicon Accounts shows it to Commit.
160#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
161#[non_exhaustive]
162pub struct SignedInAccount {
163    /// Permanent, case-sensitive account uuid: key everything on it.
164    pub uuid: String,
165    /// Current `c:`/`si:` id; it can change.
166    pub id: String,
167    /// Carbon or Silicon.
168    pub kind: AccountKind,
169    /// Display name.
170    #[serde(default, skip_serializing_if = "String::is_empty")]
171    pub display_name: String,
172    /// The email the Carbon shared with Commit (scope `email`), if any.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub email: Option<String>,
175    /// A Silicon's custodian.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub custodian: Option<AccountLink>,
178}
179
180impl SignedInAccount {
181    /// Creates a value (useful in tests and for callers restoring stored sessions).
182    pub fn new(uuid: impl Into<String>, id: impl Into<String>, kind: AccountKind) -> Self {
183        Self {
184            uuid: uuid.into(),
185            id: id.into(),
186            kind,
187            display_name: String::new(),
188            email: None,
189            custodian: None,
190        }
191    }
192}
193
194/// A reference to another account: its permanent uuid and current id.
195#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196pub struct AccountLink {
197    /// Permanent account uuid.
198    pub uuid: String,
199    /// Current `c:`/`si:` id.
200    #[serde(default)]
201    pub id: String,
202}
203
204impl SignIn {
205    fn from_tokens(tokens: TokenResponse) -> Result<Self, Error> {
206        let Some(account) = tokens.account else {
207            return Err(Error::Invalid(
208                "Silicon Accounts answered without saying which account signed in (no `account` in the token response). Retry; report it with `commit report` if it keeps happening.".into(),
209            ));
210        };
211        let custodian = account.custodian.map(|c| AccountLink {
212            uuid: c.uuid,
213            id: c.id,
214        });
215        Ok(Self {
216            access_token: SecretString::from(tokens.access_token.into_inner()),
217            refresh_token: tokens
218                .refresh_token
219                .map(|t| SecretString::from(t.into_inner())),
220            expires_in: tokens.expires_in,
221            refresh_expires_at: tokens.refresh_token_expires_at.map(|t| t.unix_timestamp()),
222            scope: tokens.scope,
223            account: SignedInAccount {
224                uuid: account.uuid,
225                id: account.id,
226                kind: account.kind,
227                display_name: account.display_name,
228                email: account.email,
229                custodian,
230            },
231        })
232    }
233}
234
235/// One poll of a device sign-in.
236#[derive(Debug)]
237#[non_exhaustive]
238pub enum DeviceStatus {
239    /// Not approved yet; poll again after the interval.
240    Pending,
241    /// Polling too fast: add 5 seconds to the interval.
242    SlowDown,
243    /// The Carbon denied the sign-in on the account site.
244    Denied,
245    /// The code expired (10 minutes) before anyone approved it.
246    Expired,
247    /// Approved.
248    SignedIn(Box<SignIn>),
249}
250
251/// What [`AccountsAuth::wait_for_device_sign_in`] reports between polls.
252#[derive(Debug)]
253#[non_exhaustive]
254pub enum DeviceEvent<'a> {
255    /// Still waiting for the Carbon.
256    Pending,
257    /// Silicon Accounts asked to poll more slowly; the new interval.
258    SlowDown {
259        /// Seconds between polls from now on.
260        interval: Duration,
261    },
262    /// A poll failed temporarily (network, 5xx, rate limit); polling continues.
263    TransientError {
264        /// The failure.
265        error: &'a Error,
266        /// Delay before the next poll.
267        retry_in: Duration,
268    },
269}
270
271/// The answer of a revocation.
272#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
273pub struct Revocation {
274    /// True when a live sign-in of this app was ended.
275    pub revoked: bool,
276    /// Why nothing was revoked (the token was unknown, already ended, or not a sign-in token).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub message: Option<String>,
279}
280
281impl AccountsAuth {
282    /// Signs in to Commit (`commit`) at the given Silicon Accounts URL.
283    pub fn new(accounts_url: impl Into<String>) -> Result<Self, Error> {
284        Self::builder(accounts_url).build()
285    }
286
287    /// Starts a builder for the given Silicon Accounts URL.
288    pub fn builder(accounts_url: impl Into<String>) -> AccountsAuthBuilder {
289        AccountsAuthBuilder {
290            url: accounts_url.into(),
291            app_id: APP_ID.to_owned(),
292            product: None,
293        }
294    }
295
296    /// The Silicon Accounts URL requests go to.
297    pub fn accounts_url(&self) -> &url::Url {
298        self.accounts.base_url()
299    }
300
301    /// The app id this client signs in to.
302    pub fn app_id(&self) -> &str {
303        &self.app_id
304    }
305
306    /// `POST /v1/device/authorize` with Commit's `client_id`: starts a Carbon's sign-in.
307    /// Show `user_code` and `verification_uri` (or `verification_uri_complete`), then poll
308    /// with [`AccountsAuth::wait_for_device_sign_in`]. `scope` asks for extra details the
309    /// Carbon may share (space-separated, e.g. `email`); `client_label` names this device in
310    /// the Carbon's sign-in history.
311    pub async fn start_device_sign_in(
312        &self,
313        scope: Option<&str>,
314        client_label: Option<&str>,
315    ) -> Result<DeviceAuthorization, Error> {
316        Ok(self
317            .accounts
318            .app_device_authorize(&self.app_id, scope, client_label)
319            .await?)
320    }
321
322    /// Polls a device sign-in once.
323    pub async fn poll_device_sign_in(
324        &self,
325        device: &DeviceAuthorization,
326    ) -> Result<DeviceStatus, Error> {
327        let poll = self
328            .accounts
329            .app_device_poll(&self.app_id, device.device_code.expose())
330            .await?;
331        Ok(match poll {
332            DevicePoll::Tokens(tokens) => {
333                DeviceStatus::SignedIn(Box::new(SignIn::from_tokens(*tokens)?))
334            }
335            DevicePoll::SlowDown => DeviceStatus::SlowDown,
336            DevicePoll::Denied => DeviceStatus::Denied,
337            DevicePoll::Expired => DeviceStatus::Expired,
338            _ => DeviceStatus::Pending,
339        })
340    }
341
342    /// Polls a device sign-in until the Carbon approves it, honouring `interval`, adding
343    /// 5 seconds after every `slow_down`, retrying temporary failures, and giving up when
344    /// the code expires. A denied sign-in fails with code `access_denied`, an expired one
345    /// with `expired_token`.
346    pub async fn wait_for_device_sign_in(
347        &self,
348        device: &DeviceAuthorization,
349        mut on_event: impl FnMut(DeviceEvent<'_>),
350    ) -> Result<SignIn, Error> {
351        let started = tokio::time::Instant::now();
352        let lifetime = Duration::from_secs(device.expires_in.max(1));
353        let mut interval = Duration::from_secs(device.interval.max(1));
354        loop {
355            tokio::time::sleep(interval).await;
356            match self.poll_device_sign_in(device).await {
357                Ok(DeviceStatus::SignedIn(sign_in)) => return Ok(*sign_in),
358                Ok(DeviceStatus::Denied) => {
359                    return Err(oauth(
360                        "access_denied",
361                        format!(
362                            "The sign-in with code {} was denied on the account site.",
363                            device.user_code
364                        ),
365                    ));
366                }
367                Ok(DeviceStatus::Expired) => return Err(expired(device)),
368                Ok(DeviceStatus::SlowDown) => {
369                    interval += SLOW_DOWN_STEP;
370                    on_event(DeviceEvent::SlowDown { interval });
371                }
372                Ok(DeviceStatus::Pending) => on_event(DeviceEvent::Pending),
373                Err(error) if error.is_transient() => on_event(DeviceEvent::TransientError {
374                    error: &error,
375                    retry_in: interval,
376                }),
377                Err(error) => return Err(error),
378            }
379            if started.elapsed() + interval > lifetime {
380                return Err(expired(device));
381            }
382        }
383    }
384
385    /// Exchanges a Silicon's short-lived token (`slt_…`, single use, 2 minutes, minted for
386    /// Commit with `silicon-accounts login --app commit -q`) for Commit's tokens, with
387    /// Commit's `client_id` alone. Commit's sign-in setup has `public_client` on. The token
388    /// is never logged or echoed in errors.
389    pub async fn exchange_slt(&self, slt: &str) -> Result<SignIn, Error> {
390        let slt = slt.trim();
391        if slt.is_empty() {
392            return Err(Error::Invalid(format!(
393                "The short-lived token is empty. Mint one for Commit with `silicon-accounts login --app {} -q` and pass it with --slt-stdin.",
394                self.app_id
395            )));
396        }
397        if !slt.starts_with("slt_") {
398            return Err(Error::Invalid(format!(
399                "That is not a Silicon Accounts short-lived token: those start with `slt_`, and this looks like {}. Mint one for Commit with `silicon-accounts login --app {} -q`.",
400                describe_token(slt),
401                self.app_id
402            )));
403        }
404        let form = [
405            ("grant_type", silicon_accounts_client::SLT_GRANT_TYPE),
406            ("slt", slt),
407            ("client_id", self.app_id.as_str()),
408        ];
409        let tokens: TokenResponse = self.post_form("token", &form).await?;
410        SignIn::from_tokens(tokens)
411    }
412
413    /// Rotates a refresh token with Commit's `client_id` alone. The old token stops working
414    /// even if this answer is lost, and presenting it again ends the sign-in: store the new
415    /// pair before using it, and never run two refreshes of one sign-in at once.
416    pub async fn refresh(&self, refresh_token: &str) -> Result<SignIn, Error> {
417        let tokens = self
418            .accounts
419            .refresh_app_public_client(&self.app_id, refresh_token)
420            .await?;
421        SignIn::from_tokens(tokens)
422    }
423
424    /// Ends the sign-in behind a refresh token (or access token) at Silicon Accounts
425    /// (`POST /v1/oauth/revoke` with Commit's `client_id`). Commit hears
426    /// `membership.signed_out` with reason `app_revoked`; the account's other sign-ins stay.
427    /// An unknown or already-ended token answers `revoked: false` with the reason.
428    pub async fn revoke(&self, token: &str) -> Result<Revocation, Error> {
429        let token = token.trim();
430        let hint = if token.starts_with("sar_") {
431            "refresh_token"
432        } else {
433            "access_token"
434        };
435        let form = [
436            ("token", token),
437            ("token_type_hint", hint),
438            ("client_id", self.app_id.as_str()),
439        ];
440        let outcome: Option<Revocation> = self.post_form("revoke", &form).await?;
441        Ok(outcome.unwrap_or(Revocation {
442            revoked: true,
443            message: None,
444        }))
445    }
446
447    /// `POST /v1/oauth/{endpoint}` with a form body, no redirects, and the service's error
448    /// bodies turned into typed Silicon Accounts errors.
449    async fn post_form<T: DeserializeOwned>(
450        &self,
451        endpoint: &str,
452        form: &[(&str, &str)],
453    ) -> Result<T, Error> {
454        let mut url = self.accounts.base_url().clone();
455        if let Ok(mut segments) = url.path_segments_mut() {
456            segments.pop_if_empty().extend(["v1", "oauth", endpoint]);
457        }
458        let what = format!("POST /v1/oauth/{endpoint}");
459        let request = self
460            .http
461            .post(url)
462            .header(ACCEPT, "application/json")
463            .header(silicon_accounts_client::TELEMETRY_HEADER, "off")
464            .form(form);
465        let response = request.send().await.map_err(|e| self.transport(e, &what))?;
466        let status = response.status();
467        let headers = response.headers().clone();
468        if response
469            .content_length()
470            .is_some_and(|n| n > MAX_BODY as u64)
471        {
472            return Err(Error::ResponseTooLarge);
473        }
474        let body = response
475            .bytes()
476            .await
477            .map_err(|e| self.transport(e, &what))?;
478        if body.len() > MAX_BODY {
479            return Err(Error::ResponseTooLarge);
480        }
481        if !status.is_success() {
482            return Err(Error::Accounts(accounts_error(
483                status, &headers, &body, &what,
484            )));
485        }
486        let body: &[u8] = if body.iter().all(u8::is_ascii_whitespace) {
487            b"null"
488        } else {
489            &body
490        };
491        serde_json::from_slice(body).map_err(|e| {
492            Error::Invalid(format!(
493                "Silicon Accounts answered {what} with HTTP {}, but not with the JSON this client expects ({e}). Check that ACCOUNTS_URL points at Silicon Accounts.",
494                status.as_u16()
495            ))
496        })
497    }
498
499    fn transport(&self, error: reqwest::Error, what: &str) -> Error {
500        let base = self
501            .accounts
502            .base_url()
503            .as_str()
504            .trim_end_matches('/')
505            .to_owned();
506        let error = error.without_url();
507        let cause = root_cause(&error);
508        let (message, hint) = if error.is_timeout() {
509            (
510                format!("Silicon Accounts at {base} did not answer {what} in time."),
511                "Retry. For a refresh, the old refresh token may already be used up; if the next try says so, sign in again.",
512            )
513        } else {
514            (
515                format!("Could not connect to Silicon Accounts at {base} ({what}): {cause}."),
516                "Check ACCOUNTS_URL and your network, then retry.",
517            )
518        };
519        Error::Accounts(silicon_accounts_client::Error::Http {
520            message,
521            hint: hint.to_owned(),
522            source: error,
523        })
524    }
525}
526
527fn oauth(code: &str, description: String) -> Error {
528    Error::Accounts(OAuthError::new(400, code, Some(description)).into())
529}
530
531fn expired(device: &DeviceAuthorization) -> Error {
532    oauth(
533        "expired_token",
534        format!(
535            "The sign-in code {} expired before it was approved (codes last {} minutes).",
536            device.user_code,
537            device.expires_in.div_ceil(60).max(1)
538        ),
539    )
540}
541
542/// Turns a non-2xx Silicon Accounts answer into its typed error (RFC 6749 body, the
543/// standard `{"error":{…}}` body, or a foreign body that is not echoed).
544fn accounts_error(
545    status: StatusCode,
546    headers: &HeaderMap,
547    body: &[u8],
548    what: &str,
549) -> silicon_accounts_client::Error {
550    let request_id = headers
551        .get(silicon_accounts_client::REQUEST_ID_HEADER)
552        .and_then(|v| v.to_str().ok())
553        .map(str::to_owned);
554    let parsed = serde_json::from_slice::<serde_json::Value>(body).ok();
555    let error = parsed.as_ref().and_then(|v| v.get("error"));
556    if let Some(code) = error.and_then(serde_json::Value::as_str) {
557        let description = parsed
558            .as_ref()
559            .and_then(|v| v.get("error_description"))
560            .and_then(serde_json::Value::as_str)
561            .map(str::to_owned);
562        let mut oauth = OAuthError::new(status.as_u16(), code, description);
563        oauth.request_id = request_id;
564        return oauth.into();
565    }
566    let text = |key: &str| {
567        error
568            .and_then(|e| e.get(key))
569            .and_then(serde_json::Value::as_str)
570            .map(str::to_owned)
571    };
572    let code = text("code").unwrap_or_else(|| format!("http_{}", status.as_u16()));
573    let message = text("message").unwrap_or_else(|| {
574        format!(
575            "Silicon Accounts answered {what} with HTTP {} {}.",
576            status.as_u16(),
577            status.canonical_reason().unwrap_or("")
578        )
579    });
580    let mut api = silicon_accounts_client::ApiError::new(status.as_u16(), code, message);
581    api.hint = text("hint");
582    api.details = error
583        .and_then(|e| e.get("details"))
584        .filter(|d| !d.is_null())
585        .cloned();
586    api.request_id = request_id;
587    api.into()
588}
589
590/// What a value that is not a short-lived token looks like, without echoing it.
591fn describe_token(value: &str) -> &'static str {
592    match value {
593        v if v.starts_with("sar_") => "a refresh token",
594        v if v.starts_with("sapr_") => "a proof refresh token",
595        v if v.starts_with("sap_") => "a proof",
596        v if v.starts_with("stk-") || v.starts_with("stk_") => {
597            "a Silicon's STK (never hand an STK to an app; mint a short-lived token instead)"
598        }
599        v if v.starts_with("eyJ") => "a JWT (an access or id token)",
600        v if v.starts_with("oac_") || v.starts_with("oat_") || v.starts_with("ort_") => {
601            "a token of the previous sign-in system, which Commit no longer accepts"
602        }
603        _ => "something else",
604    }
605}
606
607/// Why Silicon Accounts refused a short-lived token or refresh token (`invalid_grant`),
608/// from its description. `None` when the error is not such a refusal.
609#[derive(Clone, Copy, Debug, PartialEq, Eq)]
610#[non_exhaustive]
611pub enum SignInRefusal {
612    /// The token was already used (short-lived tokens work once).
613    AlreadyUsed,
614    /// The token expired (short-lived tokens last 2 minutes, sign-ins at most 900 days).
615    Expired,
616    /// The token was issued for another app.
617    WrongApp,
618    /// The token is unknown (mistyped, or from another Silicon Accounts environment).
619    Unknown,
620    /// The value is not that kind of token.
621    Malformed,
622    /// The sign-in behind it ended (signed out, STK rotated, trust removed, reuse detected).
623    SignInEnded,
624    /// The account was deleted, suspended, or removed Commit's access.
625    AccountInactive,
626    /// Another reason; read the message.
627    Other,
628}
629
630impl SignInRefusal {
631    /// Classifies an error; `None` unless it is an `invalid_grant` refusal.
632    pub fn of(error: &Error) -> Option<Self> {
633        let Error::Accounts(inner) = error else {
634            return None;
635        };
636        let oauth = inner.as_oauth().filter(|o| o.error == "invalid_grant")?;
637        let text = oauth
638            .description
639            .as_deref()
640            .unwrap_or("")
641            .to_ascii_lowercase();
642        Some(
643            if text.contains("already used") || text.contains("already exchanged") {
644                Self::AlreadyUsed
645            } else if text.contains("issued for the app") || text.contains("different app") {
646                Self::WrongApp
647            } else if text.contains("must be a") {
648                Self::Malformed
649            } else if text.contains("not known") {
650                Self::Unknown
651            } else if text.contains("expired") {
652                Self::Expired
653            } else if text.contains("revoked")
654                || text.contains("rotated")
655                || text.contains("trust")
656                || text.contains("ended")
657            {
658                Self::SignInEnded
659            } else if text.contains("no longer exists")
660                || text.contains("can't be refreshed")
661                || text.contains("removed")
662            {
663                Self::AccountInactive
664            } else {
665                Self::Other
666            },
667        )
668    }
669
670    /// Stable snake_case name, e.g. `already_used`.
671    pub fn as_str(self) -> &'static str {
672        match self {
673            Self::AlreadyUsed => "already_used",
674            Self::Expired => "expired",
675            Self::WrongApp => "wrong_app",
676            Self::Unknown => "unknown",
677            Self::Malformed => "malformed",
678            Self::SignInEnded => "sign_in_ended",
679            Self::AccountInactive => "account_inactive",
680            Self::Other => "refused",
681        }
682    }
683}
684
685pub use silicon_accounts_client::Claims;
686
687/// Reads an access token's claims WITHOUT verifying it: for display only (which account,
688/// which app, until when). Never authorize anything with the result; Commit verifies every
689/// token itself. `None` when the value is not a JWT.
690pub fn peek_claims(token: &str) -> Option<Claims> {
691    use base64::Engine as _;
692    let mut parts = token.trim().split('.');
693    let (Some(_header), Some(payload), Some(_signature), None) =
694        (parts.next(), parts.next(), parts.next(), parts.next())
695    else {
696        return None;
697    };
698    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
699        .decode(payload.trim_end_matches('='))
700        .ok()?;
701    serde_json::from_slice(&bytes).ok()
702}