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    telemetry: bool,
43}
44
45impl fmt::Debug for AccountsAuth {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        f.debug_struct("AccountsAuth")
48            .field("accounts_url", &self.accounts.base_url().as_str())
49            .field("app_id", &self.app_id)
50            .finish_non_exhaustive()
51    }
52}
53
54/// Builder for [`AccountsAuth`].
55#[derive(Clone, Debug)]
56pub struct AccountsAuthBuilder {
57    url: String,
58    app_id: String,
59    telemetry: bool,
60    product: Option<String>,
61}
62
63impl AccountsAuthBuilder {
64    /// The app id to sign in to (default `commit`; a development deployment may use another).
65    #[must_use]
66    pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
67        self.app_id = app_id.into();
68        self
69    }
70
71    /// When false, requests carry `X-Accounts-Telemetry: off`. Default true.
72    #[must_use]
73    pub fn telemetry(mut self, enabled: bool) -> Self {
74        self.telemetry = enabled;
75        self
76    }
77
78    /// A product token prepended to the User-Agent, e.g. `silicon-commit-cli/0.5.0`.
79    #[must_use]
80    pub fn user_agent(mut self, product: impl Into<String>) -> Self {
81        self.product = Some(product.into());
82        self
83    }
84
85    /// Builds the client. Plain `http://` is accepted only for this machine
86    /// (`localhost`, `127.0.0.1`, `::1`), so tokens never cross a network unencrypted.
87    pub fn build(self) -> Result<AccountsAuth, Error> {
88        let app_id = self.app_id.trim().to_owned();
89        if app_id.is_empty()
90            || app_id.len() > 64
91            || !app_id
92                .bytes()
93                .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_'))
94        {
95            return Err(Error::Invalid(format!(
96                "`{app_id}` is not an app id: use lowercase letters, digits, `-` and `_` (Commit's is `{APP_ID}`)."
97            )));
98        }
99        let product = self
100            .product
101            .unwrap_or_else(|| format!("silicon-commit-client/{}", env!("CARGO_PKG_VERSION")));
102        let accounts = AccountsClient::builder()
103            .base_url(self.url)
104            .telemetry(self.telemetry)
105            .user_agent(product.clone())
106            .build()?;
107        let http = reqwest::Client::builder()
108            .redirect(reqwest::redirect::Policy::none())
109            .connect_timeout(Duration::from_secs(10))
110            .timeout(Duration::from_secs(30))
111            .user_agent(product)
112            .build()
113            .map_err(|e| {
114                Error::Invalid(format!(
115                    "Could not create the HTTP client: {}.",
116                    root_cause(&e)
117                ))
118            })?;
119        Ok(AccountsAuth {
120            accounts,
121            http,
122            app_id,
123            telemetry: self.telemetry,
124        })
125    }
126}
127
128/// A finished sign-in: Commit's tokens and the account they belong to.
129#[derive(Clone)]
130#[non_exhaustive]
131pub struct SignIn {
132    /// Access token for the Commit API (`Authorization: Bearer …`).
133    pub access_token: SecretString,
134    /// Rotating refresh token (`sar_…`); store the newest one before using it.
135    pub refresh_token: Option<SecretString>,
136    /// Seconds until the access token expires, counted from when the answer arrived.
137    pub expires_in: u64,
138    /// When the sign-in ends however often it is refreshed (unix seconds), when known.
139    pub refresh_expires_at: Option<i64>,
140    /// Space-separated details the account shared with Commit (`profile`, `email`, …).
141    pub scope: Option<String>,
142    /// Who signed in.
143    pub account: SignedInAccount,
144}
145
146impl fmt::Debug for SignIn {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.debug_struct("SignIn")
149            .field("tokens", &"<redacted>")
150            .field("expires_in", &self.expires_in)
151            .field("refresh_expires_at", &self.refresh_expires_at)
152            .field("scope", &self.scope)
153            .field("account", &self.account)
154            .finish()
155    }
156}
157
158/// The account a sign-in belongs to, as Silicon Accounts shows it to Commit.
159#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[non_exhaustive]
161pub struct SignedInAccount {
162    /// Permanent, case-sensitive account uuid: key everything on it.
163    pub uuid: String,
164    /// Current `c:`/`si:` id; it can change.
165    pub id: String,
166    /// Carbon or Silicon.
167    pub kind: AccountKind,
168    /// Display name.
169    #[serde(default, skip_serializing_if = "String::is_empty")]
170    pub display_name: String,
171    /// The email the Carbon shared with Commit (scope `email`), if any.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub email: Option<String>,
174    /// A Silicon's custodian.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub custodian: Option<AccountLink>,
177}
178
179impl SignedInAccount {
180    /// Creates a value (useful in tests and for callers restoring stored sessions).
181    pub fn new(uuid: impl Into<String>, id: impl Into<String>, kind: AccountKind) -> Self {
182        Self {
183            uuid: uuid.into(),
184            id: id.into(),
185            kind,
186            display_name: String::new(),
187            email: None,
188            custodian: None,
189        }
190    }
191}
192
193/// A reference to another account: its permanent uuid and current id.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct AccountLink {
196    /// Permanent account uuid.
197    pub uuid: String,
198    /// Current `c:`/`si:` id.
199    #[serde(default)]
200    pub id: String,
201}
202
203impl SignIn {
204    fn from_tokens(tokens: TokenResponse) -> Result<Self, Error> {
205        let Some(account) = tokens.account else {
206            return Err(Error::Invalid(
207                "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(),
208            ));
209        };
210        let custodian = account.custodian.map(|c| AccountLink {
211            uuid: c.uuid,
212            id: c.id,
213        });
214        Ok(Self {
215            access_token: SecretString::from(tokens.access_token.into_inner()),
216            refresh_token: tokens
217                .refresh_token
218                .map(|t| SecretString::from(t.into_inner())),
219            expires_in: tokens.expires_in,
220            refresh_expires_at: tokens.refresh_token_expires_at.map(|t| t.unix_timestamp()),
221            scope: tokens.scope,
222            account: SignedInAccount {
223                uuid: account.uuid,
224                id: account.id,
225                kind: account.kind,
226                display_name: account.display_name,
227                email: account.email,
228                custodian,
229            },
230        })
231    }
232}
233
234/// One poll of a device sign-in.
235#[derive(Debug)]
236#[non_exhaustive]
237pub enum DeviceStatus {
238    /// Not approved yet; poll again after the interval.
239    Pending,
240    /// Polling too fast: add 5 seconds to the interval.
241    SlowDown,
242    /// The Carbon denied the sign-in on the account site.
243    Denied,
244    /// The code expired (10 minutes) before anyone approved it.
245    Expired,
246    /// Approved.
247    SignedIn(Box<SignIn>),
248}
249
250/// What [`AccountsAuth::wait_for_device_sign_in`] reports between polls.
251#[derive(Debug)]
252#[non_exhaustive]
253pub enum DeviceEvent<'a> {
254    /// Still waiting for the Carbon.
255    Pending,
256    /// Silicon Accounts asked to poll more slowly; the new interval.
257    SlowDown {
258        /// Seconds between polls from now on.
259        interval: Duration,
260    },
261    /// A poll failed temporarily (network, 5xx, rate limit); polling continues.
262    TransientError {
263        /// The failure.
264        error: &'a Error,
265        /// Delay before the next poll.
266        retry_in: Duration,
267    },
268}
269
270/// The answer of a revocation.
271#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
272pub struct Revocation {
273    /// True when a live sign-in of this app was ended.
274    pub revoked: bool,
275    /// Why nothing was revoked (the token was unknown, already ended, or not a sign-in token).
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub message: Option<String>,
278}
279
280impl AccountsAuth {
281    /// Signs in to Commit (`commit`) at the given Silicon Accounts URL.
282    pub fn new(accounts_url: impl Into<String>) -> Result<Self, Error> {
283        Self::builder(accounts_url).build()
284    }
285
286    /// Starts a builder for the given Silicon Accounts URL.
287    pub fn builder(accounts_url: impl Into<String>) -> AccountsAuthBuilder {
288        AccountsAuthBuilder {
289            url: accounts_url.into(),
290            app_id: APP_ID.to_owned(),
291            telemetry: true,
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 mut request = self
460            .http
461            .post(url)
462            .header(ACCEPT, "application/json")
463            .form(form);
464        if !self.telemetry {
465            request = request.header(silicon_accounts_client::TELEMETRY_HEADER, "off");
466        }
467        let response = request.send().await.map_err(|e| self.transport(e, &what))?;
468        let status = response.status();
469        let headers = response.headers().clone();
470        if response
471            .content_length()
472            .is_some_and(|n| n > MAX_BODY as u64)
473        {
474            return Err(Error::ResponseTooLarge);
475        }
476        let body = response
477            .bytes()
478            .await
479            .map_err(|e| self.transport(e, &what))?;
480        if body.len() > MAX_BODY {
481            return Err(Error::ResponseTooLarge);
482        }
483        if !status.is_success() {
484            return Err(Error::Accounts(accounts_error(
485                status, &headers, &body, &what,
486            )));
487        }
488        let body: &[u8] = if body.iter().all(u8::is_ascii_whitespace) {
489            b"null"
490        } else {
491            &body
492        };
493        serde_json::from_slice(body).map_err(|e| {
494            Error::Invalid(format!(
495                "Silicon Accounts answered {what} with HTTP {}, but not with the JSON this client expects ({e}). Check that ACCOUNTS_URL points at Silicon Accounts.",
496                status.as_u16()
497            ))
498        })
499    }
500
501    fn transport(&self, error: reqwest::Error, what: &str) -> Error {
502        let base = self
503            .accounts
504            .base_url()
505            .as_str()
506            .trim_end_matches('/')
507            .to_owned();
508        let error = error.without_url();
509        let cause = root_cause(&error);
510        let (message, hint) = if error.is_timeout() {
511            (
512                format!("Silicon Accounts at {base} did not answer {what} in time."),
513                "Retry. For a refresh, the old refresh token may already be used up; if the next try says so, sign in again.",
514            )
515        } else {
516            (
517                format!("Could not connect to Silicon Accounts at {base} ({what}): {cause}."),
518                "Check ACCOUNTS_URL and your network, then retry.",
519            )
520        };
521        Error::Accounts(silicon_accounts_client::Error::Http {
522            message,
523            hint: hint.to_owned(),
524            source: error,
525        })
526    }
527}
528
529fn oauth(code: &str, description: String) -> Error {
530    Error::Accounts(OAuthError::new(400, code, Some(description)).into())
531}
532
533fn expired(device: &DeviceAuthorization) -> Error {
534    oauth(
535        "expired_token",
536        format!(
537            "The sign-in code {} expired before it was approved (codes last {} minutes).",
538            device.user_code,
539            device.expires_in.div_ceil(60).max(1)
540        ),
541    )
542}
543
544/// Turns a non-2xx Silicon Accounts answer into its typed error (RFC 6749 body, the
545/// standard `{"error":{…}}` body, or a foreign body that is not echoed).
546fn accounts_error(
547    status: StatusCode,
548    headers: &HeaderMap,
549    body: &[u8],
550    what: &str,
551) -> silicon_accounts_client::Error {
552    let request_id = headers
553        .get(silicon_accounts_client::REQUEST_ID_HEADER)
554        .and_then(|v| v.to_str().ok())
555        .map(str::to_owned);
556    let parsed = serde_json::from_slice::<serde_json::Value>(body).ok();
557    let error = parsed.as_ref().and_then(|v| v.get("error"));
558    if let Some(code) = error.and_then(serde_json::Value::as_str) {
559        let description = parsed
560            .as_ref()
561            .and_then(|v| v.get("error_description"))
562            .and_then(serde_json::Value::as_str)
563            .map(str::to_owned);
564        let mut oauth = OAuthError::new(status.as_u16(), code, description);
565        oauth.request_id = request_id;
566        return oauth.into();
567    }
568    let text = |key: &str| {
569        error
570            .and_then(|e| e.get(key))
571            .and_then(serde_json::Value::as_str)
572            .map(str::to_owned)
573    };
574    let code = text("code").unwrap_or_else(|| format!("http_{}", status.as_u16()));
575    let message = text("message").unwrap_or_else(|| {
576        format!(
577            "Silicon Accounts answered {what} with HTTP {} {}.",
578            status.as_u16(),
579            status.canonical_reason().unwrap_or("")
580        )
581    });
582    let mut api = silicon_accounts_client::ApiError::new(status.as_u16(), code, message);
583    api.hint = text("hint");
584    api.details = error
585        .and_then(|e| e.get("details"))
586        .filter(|d| !d.is_null())
587        .cloned();
588    api.request_id = request_id;
589    api.into()
590}
591
592/// What a value that is not a short-lived token looks like, without echoing it.
593fn describe_token(value: &str) -> &'static str {
594    match value {
595        v if v.starts_with("sar_") => "a refresh token",
596        v if v.starts_with("sapr_") => "a proof refresh token",
597        v if v.starts_with("sap_") => "a proof",
598        v if v.starts_with("stk-") || v.starts_with("stk_") => {
599            "a Silicon's STK (never hand an STK to an app; mint a short-lived token instead)"
600        }
601        v if v.starts_with("eyJ") => "a JWT (an access or id token)",
602        v if v.starts_with("oac_") || v.starts_with("oat_") || v.starts_with("ort_") => {
603            "a token of the previous sign-in system, which Commit no longer accepts"
604        }
605        _ => "something else",
606    }
607}
608
609/// Why Silicon Accounts refused a short-lived token or refresh token (`invalid_grant`),
610/// from its description. `None` when the error is not such a refusal.
611#[derive(Clone, Copy, Debug, PartialEq, Eq)]
612#[non_exhaustive]
613pub enum SignInRefusal {
614    /// The token was already used (short-lived tokens work once).
615    AlreadyUsed,
616    /// The token expired (short-lived tokens last 2 minutes, sign-ins at most 900 days).
617    Expired,
618    /// The token was issued for another app.
619    WrongApp,
620    /// The token is unknown (mistyped, or from another Silicon Accounts environment).
621    Unknown,
622    /// The value is not that kind of token.
623    Malformed,
624    /// The sign-in behind it ended (signed out, STK rotated, trust removed, reuse detected).
625    SignInEnded,
626    /// The account was deleted, suspended, or removed Commit's access.
627    AccountInactive,
628    /// Another reason; read the message.
629    Other,
630}
631
632impl SignInRefusal {
633    /// Classifies an error; `None` unless it is an `invalid_grant` refusal.
634    pub fn of(error: &Error) -> Option<Self> {
635        let Error::Accounts(inner) = error else {
636            return None;
637        };
638        let oauth = inner.as_oauth().filter(|o| o.error == "invalid_grant")?;
639        let text = oauth
640            .description
641            .as_deref()
642            .unwrap_or("")
643            .to_ascii_lowercase();
644        Some(
645            if text.contains("already used") || text.contains("already exchanged") {
646                Self::AlreadyUsed
647            } else if text.contains("issued for the app") || text.contains("different app") {
648                Self::WrongApp
649            } else if text.contains("must be a") {
650                Self::Malformed
651            } else if text.contains("not known") {
652                Self::Unknown
653            } else if text.contains("expired") {
654                Self::Expired
655            } else if text.contains("revoked")
656                || text.contains("rotated")
657                || text.contains("trust")
658                || text.contains("ended")
659            {
660                Self::SignInEnded
661            } else if text.contains("no longer exists")
662                || text.contains("can't be refreshed")
663                || text.contains("removed")
664            {
665                Self::AccountInactive
666            } else {
667                Self::Other
668            },
669        )
670    }
671
672    /// Stable snake_case name, e.g. `already_used`.
673    pub fn as_str(self) -> &'static str {
674        match self {
675            Self::AlreadyUsed => "already_used",
676            Self::Expired => "expired",
677            Self::WrongApp => "wrong_app",
678            Self::Unknown => "unknown",
679            Self::Malformed => "malformed",
680            Self::SignInEnded => "sign_in_ended",
681            Self::AccountInactive => "account_inactive",
682            Self::Other => "refused",
683        }
684    }
685}
686
687pub use silicon_accounts_client::Claims;
688
689/// Reads an access token's claims WITHOUT verifying it: for display only (which account,
690/// which app, until when). Never authorize anything with the result; Commit verifies every
691/// token itself. `None` when the value is not a JWT.
692pub fn peek_claims(token: &str) -> Option<Claims> {
693    use base64::Engine as _;
694    let mut parts = token.trim().split('.');
695    let (Some(_header), Some(payload), Some(_signature), None) =
696        (parts.next(), parts.next(), parts.next(), parts.next())
697    else {
698        return None;
699    };
700    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
701        .decode(payload.trim_end_matches('='))
702        .ok()?;
703    serde_json::from_slice(&bytes).ok()
704}