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 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#[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 #[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 #[must_use]
73 pub fn telemetry(mut self, enabled: bool) -> Self {
74 self.telemetry = enabled;
75 self
76 }
77
78 #[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 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#[derive(Clone)]
130#[non_exhaustive]
131pub struct SignIn {
132 pub access_token: SecretString,
134 pub refresh_token: Option<SecretString>,
136 pub expires_in: u64,
138 pub refresh_expires_at: Option<i64>,
140 pub scope: Option<String>,
142 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160#[non_exhaustive]
161pub struct SignedInAccount {
162 pub uuid: String,
164 pub id: String,
166 pub kind: AccountKind,
168 #[serde(default, skip_serializing_if = "String::is_empty")]
170 pub display_name: String,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub email: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub custodian: Option<AccountLink>,
177}
178
179impl SignedInAccount {
180 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
195pub struct AccountLink {
196 pub uuid: String,
198 #[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#[derive(Debug)]
236#[non_exhaustive]
237pub enum DeviceStatus {
238 Pending,
240 SlowDown,
242 Denied,
244 Expired,
246 SignedIn(Box<SignIn>),
248}
249
250#[derive(Debug)]
252#[non_exhaustive]
253pub enum DeviceEvent<'a> {
254 Pending,
256 SlowDown {
258 interval: Duration,
260 },
261 TransientError {
263 error: &'a Error,
265 retry_in: Duration,
267 },
268}
269
270#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
272pub struct Revocation {
273 pub revoked: bool,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub message: Option<String>,
278}
279
280impl AccountsAuth {
281 pub fn new(accounts_url: impl Into<String>) -> Result<Self, Error> {
283 Self::builder(accounts_url).build()
284 }
285
286 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 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 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
544fn 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
592fn 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
612#[non_exhaustive]
613pub enum SignInRefusal {
614 AlreadyUsed,
616 Expired,
618 WrongApp,
620 Unknown,
622 Malformed,
624 SignInEnded,
626 AccountInactive,
628 Other,
630}
631
632impl SignInRefusal {
633 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 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
689pub 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}