1use 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
27pub const APP_ID: &str = "commit";
29pub const DEFAULT_ACCOUNTS_URL: &str = silicon_accounts_client::DEFAULT_BASE_URL;
31const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
33const MAX_BODY: usize = 1024 * 1024;
35
36#[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#[derive(Clone, Debug)]
55pub struct AccountsAuthBuilder {
56 url: String,
57 app_id: String,
58 product: Option<String>,
59}
60
61impl AccountsAuthBuilder {
62 #[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 #[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 #[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 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#[derive(Clone)]
131#[non_exhaustive]
132pub struct SignIn {
133 pub access_token: SecretString,
135 pub refresh_token: Option<SecretString>,
137 pub expires_in: u64,
139 pub refresh_expires_at: Option<i64>,
141 pub scope: Option<String>,
143 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
161#[non_exhaustive]
162pub struct SignedInAccount {
163 pub uuid: String,
165 pub id: String,
167 pub kind: AccountKind,
169 #[serde(default, skip_serializing_if = "String::is_empty")]
171 pub display_name: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub email: Option<String>,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub custodian: Option<AccountLink>,
178}
179
180impl SignedInAccount {
181 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
196pub struct AccountLink {
197 pub uuid: String,
199 #[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#[derive(Debug)]
237#[non_exhaustive]
238pub enum DeviceStatus {
239 Pending,
241 SlowDown,
243 Denied,
245 Expired,
247 SignedIn(Box<SignIn>),
249}
250
251#[derive(Debug)]
253#[non_exhaustive]
254pub enum DeviceEvent<'a> {
255 Pending,
257 SlowDown {
259 interval: Duration,
261 },
262 TransientError {
264 error: &'a Error,
266 retry_in: Duration,
268 },
269}
270
271#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
273pub struct Revocation {
274 pub revoked: bool,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub message: Option<String>,
279}
280
281impl AccountsAuth {
282 pub fn new(accounts_url: impl Into<String>) -> Result<Self, Error> {
284 Self::builder(accounts_url).build()
285 }
286
287 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 pub fn accounts_url(&self) -> &url::Url {
298 self.accounts.base_url()
299 }
300
301 pub fn app_id(&self) -> &str {
303 &self.app_id
304 }
305
306 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 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 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 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 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 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 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
542fn 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
590fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
610#[non_exhaustive]
611pub enum SignInRefusal {
612 AlreadyUsed,
614 Expired,
616 WrongApp,
618 Unknown,
620 Malformed,
622 SignInEnded,
624 AccountInactive,
626 Other,
628}
629
630impl SignInRefusal {
631 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 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
687pub 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}