1use std::{
34 fmt,
35 sync::atomic::{AtomicBool, Ordering},
36 time::Duration,
37};
38
39use async_trait::async_trait;
40use reqwest::Client;
41use serde::Deserialize;
42use url::Url;
43
44use super::{now_millis, open_browser, OAuthCredentials};
45
46pub const PROVIDER: &str = "muse";
50
51pub const CLIENT_ID: &str = "1031625952748946";
54
55pub const OAUTH_HOST: &str = "auth.meta.com";
57
58pub const DEVICE_AUTH_URL: &str = "https://auth.meta.com/oidc/device/authorization/";
61
62pub const TOKEN_URL: &str = "https://auth.meta.com/oidc/device/token/";
64
65pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
67
68pub const API_HOST: &str = "api.meta.ai";
70
71pub const API_ORIGIN: &str = "https://api.meta.ai";
74
75pub const API_BASE_URL: &str = "https://api.meta.ai/v1";
77
78pub const MINT_URL: &str = "https://api.meta.ai/muse-code/key";
80
81pub const MINT_API_VERSION: &str = "1.0.0";
83
84pub const ONBOARDING_URL: &str = "https://dev.meta.ai";
86
87pub const API_KEY_ENV: &str = "META_API_KEY";
90
91pub const ONBOARDING_REQUIRED_CODE: &str = "MODEL_API__ONBOARDING_REQUIRED";
94
95pub const USER_AGENT: &str = concat!("SynapsCLI/", env!("CARGO_PKG_VERSION"));
97
98pub const API_KEY_NOMINAL_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1000;
104
105const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
106const MAX_POLL_INTERVAL_SECS: u64 = 30;
108const SLOW_DOWN_STEP_SECS: u64 = 5;
110const DEFAULT_DEVICE_EXPIRES_IN_SECS: u64 = 600;
112
113pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
115pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
117pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
119const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
121
122#[derive(Clone, PartialEq, Eq)]
128pub enum MuseAuthError {
129 UntrustedEndpoint,
130 InvalidDeviceResponse,
131 InvalidTokenResponse,
132 InvalidMintResponse,
133 AccessDenied,
134 Expired,
135 Cancelled,
136 EmptyAccessToken,
137 EmptyApiKey,
138 OnboardingRequired,
140 PaymentRequired,
142 LoginRejected(u16),
144 HttpStatus(u16),
145 Transport,
146 ResponseTooLarge,
147 Persist,
148 CorruptStore(String),
151}
152
153impl fmt::Display for MuseAuthError {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::UntrustedEndpoint => f.write_str("untrusted endpoint"),
157 Self::InvalidDeviceResponse => f.write_str("invalid device authorization response"),
158 Self::InvalidTokenResponse => f.write_str("invalid device token response"),
159 Self::InvalidMintResponse => f.write_str("invalid Muse key mint response"),
160 Self::AccessDenied => f.write_str("access denied"),
161 Self::Expired => f.write_str("device code expired"),
162 Self::Cancelled => f.write_str("login cancelled"),
163 Self::EmptyAccessToken => f.write_str("empty Meta access token"),
164 Self::EmptyApiKey => f.write_str("Muse key mint returned an empty API key"),
165 Self::OnboardingRequired => write!(
166 f,
167 "your Meta account has not finished Model API onboarding — finish it at \
168 {ONBOARDING_URL}, then run `synaps login --provider muse` again"
169 ),
170 Self::PaymentRequired => write!(
171 f,
172 "your Meta account needs an active Model API plan or payment method — set it \
173 up at {ONBOARDING_URL}, then retry"
174 ),
175 Self::LoginRejected(code) => write!(
176 f,
177 "your saved Meta login is no longer valid (HTTP {code}); run \
178 `synaps login --provider muse` again"
179 ),
180 Self::HttpStatus(code) => write!(f, "HTTP error {code}"),
181 Self::Transport => f.write_str("transport error"),
182 Self::ResponseTooLarge => f.write_str("response body too large"),
183 Self::Persist => f.write_str("credential persistence failed"),
184 Self::CorruptStore(msg) => f.write_str(msg),
185 }
186 }
187}
188
189impl MuseAuthError {
190 pub(crate) fn from_store(e: super::storage::StoreError) -> Self {
192 match e {
193 super::storage::StoreError::Corrupt(msg) => Self::CorruptStore(msg),
194 _ => Self::Persist,
195 }
196 }
197
198 pub fn refresh_failure(&self) -> super::RefreshFailure {
202 use super::token::RefreshFailureKind as K;
203 let kind = match self {
204 Self::HttpStatus(code) => super::token::status_kind(*code),
205 Self::PaymentRequired => K::ActionRequired,
207 Self::LoginRejected(_)
208 | Self::OnboardingRequired
209 | Self::EmptyAccessToken
210 | Self::AccessDenied
211 | Self::Expired => K::Credential,
212 Self::Transport
213 | Self::ResponseTooLarge
214 | Self::InvalidMintResponse
215 | Self::InvalidTokenResponse
216 | Self::EmptyApiKey => K::Transient,
217 _ => K::Other,
218 };
219 super::RefreshFailure::new(kind, self.to_string())
220 }
221}
222
223impl fmt::Debug for MuseAuthError {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 write!(f, "MuseAuthError({self})")
226 }
227}
228
229impl From<MuseAuthError> for String {
230 fn from(value: MuseAuthError) -> Self {
231 value.to_string()
232 }
233}
234
235#[derive(Clone)]
239pub struct MuseHttpResponse {
240 pub status: u16,
241 pub body: String,
242}
243
244impl fmt::Debug for MuseHttpResponse {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 f.debug_struct("MuseHttpResponse")
247 .field("status", &self.status)
248 .field(
249 "body",
250 &format_args!("[REDACTED {} bytes]", self.body.len()),
251 )
252 .finish()
253 }
254}
255
256#[async_trait]
258pub trait MuseHttp: Send + Sync {
259 async fn post_form(
261 &self,
262 url: &str,
263 form: &[(&str, &str)],
264 ) -> Result<MuseHttpResponse, MuseAuthError>;
265
266 async fn post_json_bearer(
268 &self,
269 url: &str,
270 bearer: &str,
271 headers: &[(&str, &str)],
272 body: &str,
273 ) -> Result<MuseHttpResponse, MuseAuthError>;
274}
275
276#[async_trait]
278pub trait MuseClock: Send + Sync {
279 fn now_millis(&self) -> u64;
280
281 async fn sleep_cancellable(
284 &self,
285 duration: Duration,
286 cancel: &AtomicBool,
287 ) -> Result<(), MuseAuthError>;
288}
289
290pub trait MuseBrowser: Send + Sync {
292 fn open(&self, url: &str) -> Result<(), MuseAuthError>;
293}
294
295#[derive(Clone)]
298pub struct DeviceAuthorization {
299 pub(crate) device_code: String,
301 pub user_code: String,
302 pub verification_uri: String,
303 pub verification_uri_complete: Option<String>,
305 pub expires_in_secs: u64,
306 pub interval_secs: u64,
307 pub issued_at_ms: u64,
308}
309
310impl fmt::Debug for DeviceAuthorization {
311 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312 f.debug_struct("DeviceAuthorization")
313 .field("device_code", &"[REDACTED]")
314 .field("user_code", &self.user_code)
315 .field("verification_uri", &self.verification_uri)
316 .field("verification_uri_complete", &self.verification_uri_complete)
317 .field("expires_in_secs", &self.expires_in_secs)
318 .field("interval_secs", &self.interval_secs)
319 .field("issued_at_ms", &self.issued_at_ms)
320 .finish()
321 }
322}
323
324impl DeviceAuthorization {
325 pub fn browser_uri(&self) -> &str {
328 self.verification_uri_complete
329 .as_deref()
330 .unwrap_or(&self.verification_uri)
331 }
332}
333
334#[derive(Clone, PartialEq, Eq)]
336pub enum DevicePollOutcome {
337 Pending,
338 SlowDown,
339 Authorized { access_token: String },
340 Denied,
341 Expired,
342 OtherError,
343}
344
345impl fmt::Debug for DevicePollOutcome {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match self {
348 Self::Pending => f.write_str("Pending"),
349 Self::SlowDown => f.write_str("SlowDown"),
350 Self::Authorized { .. } => f
351 .debug_struct("Authorized")
352 .field("access_token", &"[REDACTED]")
353 .finish(),
354 Self::Denied => f.write_str("Denied"),
355 Self::Expired => f.write_str("Expired"),
356 Self::OtherError => f.write_str("OtherError"),
357 }
358 }
359}
360
361#[derive(Clone, PartialEq, Eq)]
363pub struct MintedKey {
364 pub api_key: String,
365 pub user_email: Option<String>,
366 pub subs_active: Option<bool>,
367}
368
369impl fmt::Debug for MintedKey {
370 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371 f.debug_struct("MintedKey")
372 .field("api_key", &"[REDACTED]")
373 .field("user_email", &self.user_email)
374 .field("subs_active", &self.subs_active)
375 .finish()
376 }
377}
378
379pub fn validate_device_endpoint(url: &str) -> Result<(), MuseAuthError> {
383 if url != DEVICE_AUTH_URL && url != TOKEN_URL {
384 return Err(MuseAuthError::UntrustedEndpoint);
385 }
386 let parsed = Url::parse(url).map_err(|_| MuseAuthError::UntrustedEndpoint)?;
387 if parsed.scheme() != "https" || parsed.host_str() != Some(OAUTH_HOST) {
388 return Err(MuseAuthError::UntrustedEndpoint);
389 }
390 Ok(())
391}
392
393pub fn validate_mint_endpoint(url: &str) -> Result<(), MuseAuthError> {
395 if url != MINT_URL {
396 return Err(MuseAuthError::UntrustedEndpoint);
397 }
398 let parsed = Url::parse(url).map_err(|_| MuseAuthError::UntrustedEndpoint)?;
399 if parsed.scheme() != "https"
400 || parsed.host_str() != Some(API_HOST)
401 || parsed.path() != "/muse-code/key"
402 || parsed.query().is_some()
403 {
404 return Err(MuseAuthError::UntrustedEndpoint);
405 }
406 Ok(())
407}
408
409pub fn validate_verification_uri(uri: &str) -> Result<(), MuseAuthError> {
414 let parsed = Url::parse(uri).map_err(|_| MuseAuthError::UntrustedEndpoint)?;
415 if parsed.scheme() != "https"
416 || !parsed.username().is_empty()
417 || parsed.password().is_some()
418 || parsed.port().is_some()
419 || parsed.fragment().is_some()
420 {
421 return Err(MuseAuthError::UntrustedEndpoint);
422 }
423 let host = parsed.host_str().ok_or(MuseAuthError::UntrustedEndpoint)?;
424 let meta_owned = ["meta.com", "meta.ai"]
425 .iter()
426 .any(|root| host == *root || host.ends_with(&format!(".{root}")));
427 if !meta_owned {
428 return Err(MuseAuthError::UntrustedEndpoint);
429 }
430 Ok(())
431}
432
433pub fn mint_request_headers() -> &'static [(&'static str, &'static str)] {
438 &[
439 ("Accept", "application/json"),
440 ("Content-Type", "application/json"),
441 ("x-api-version", MINT_API_VERSION),
442 ("User-Agent", USER_AGENT),
443 ]
444}
445
446pub const MINT_BODY: &str = r#"{"onboard":false}"#;
448
449#[derive(Deserialize)]
452struct DeviceCodeJson {
453 device_code: String,
454 user_code: String,
455 verification_uri: String,
456 #[serde(default)]
457 verification_uri_complete: Option<String>,
458 #[serde(default)]
459 expires_in: Option<u64>,
460 #[serde(default)]
461 interval: Option<u64>,
462}
463
464impl fmt::Debug for DeviceCodeJson {
465 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
466 f.debug_struct("DeviceCodeJson")
467 .field("device_code", &"[REDACTED]")
468 .field("user_code", &self.user_code)
469 .field("verification_uri", &self.verification_uri)
470 .field("verification_uri_complete", &self.verification_uri_complete)
471 .field("expires_in", &self.expires_in)
472 .field("interval", &self.interval)
473 .finish()
474 }
475}
476
477#[derive(Deserialize)]
478struct DeviceTokenJson {
479 #[serde(default)]
480 access_token: Option<String>,
481 #[serde(default)]
482 error: Option<String>,
483}
484
485impl fmt::Debug for DeviceTokenJson {
486 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487 f.debug_struct("DeviceTokenJson")
488 .field(
489 "access_token",
490 &self.access_token.as_ref().map(|_| "[REDACTED]"),
491 )
492 .field("error", &self.error)
493 .finish()
494 }
495}
496
497#[derive(Deserialize)]
498struct MintJson {
499 #[serde(default)]
500 api_key: Option<String>,
501 #[serde(default)]
502 user_email: Option<String>,
503 #[serde(default)]
504 is_subs_active: Option<bool>,
505 #[serde(default)]
506 require_payment: Option<bool>,
507}
508
509pub fn parse_device_code_response(
510 body: &str,
511 now_ms: u64,
512) -> Result<DeviceAuthorization, MuseAuthError> {
513 let raw: DeviceCodeJson =
514 serde_json::from_str(body).map_err(|_| MuseAuthError::InvalidDeviceResponse)?;
515 if raw.device_code.trim().is_empty() || raw.user_code.trim().is_empty() {
516 return Err(MuseAuthError::InvalidDeviceResponse);
517 }
518 validate_verification_uri(&raw.verification_uri)?;
519 let verification_uri_complete = raw
522 .verification_uri_complete
523 .filter(|uri| validate_verification_uri(uri).is_ok());
524 let expires_in = match raw.expires_in {
525 Some(0) => return Err(MuseAuthError::InvalidDeviceResponse),
526 Some(secs) => secs,
527 None => DEFAULT_DEVICE_EXPIRES_IN_SECS,
528 };
529 let interval = raw
530 .interval
531 .unwrap_or(DEFAULT_POLL_INTERVAL_SECS)
532 .clamp(1, MAX_POLL_INTERVAL_SECS);
533 Ok(DeviceAuthorization {
534 device_code: raw.device_code,
535 user_code: raw.user_code,
536 verification_uri: raw.verification_uri,
537 verification_uri_complete,
538 expires_in_secs: expires_in,
539 interval_secs: interval,
540 issued_at_ms: now_ms,
541 })
542}
543
544pub fn parse_device_poll_response(body: &str) -> Result<DevicePollOutcome, MuseAuthError> {
545 let raw: DeviceTokenJson =
546 serde_json::from_str(body).map_err(|_| MuseAuthError::InvalidTokenResponse)?;
547 if let Some(token) = raw.access_token {
548 if token.trim().is_empty() {
549 return Err(MuseAuthError::EmptyAccessToken);
550 }
551 return Ok(DevicePollOutcome::Authorized {
552 access_token: token,
553 });
554 }
555 Ok(match raw.error.as_deref().unwrap_or("") {
556 "authorization_pending" => DevicePollOutcome::Pending,
557 "slow_down" => DevicePollOutcome::SlowDown,
558 "access_denied" => DevicePollOutcome::Denied,
559 "expired_token" => DevicePollOutcome::Expired,
560 _ => DevicePollOutcome::OtherError,
561 })
562}
563
564pub fn parse_mint_response(status: u16, body: &str) -> Result<MintedKey, MuseAuthError> {
568 if body.contains(ONBOARDING_REQUIRED_CODE) {
569 return Err(MuseAuthError::OnboardingRequired);
570 }
571 if status == 401 || status == 403 {
572 return Err(MuseAuthError::LoginRejected(status));
573 }
574 if !(200..300).contains(&status) {
575 return Err(MuseAuthError::HttpStatus(status));
576 }
577 let raw: MintJson =
578 serde_json::from_str(body).map_err(|_| MuseAuthError::InvalidMintResponse)?;
579 match raw.api_key.filter(|key| !key.trim().is_empty()) {
580 Some(api_key) => Ok(MintedKey {
581 api_key,
582 user_email: raw.user_email.filter(|e| !e.trim().is_empty()),
583 subs_active: raw.is_subs_active,
584 }),
585 None if raw.require_payment == Some(true) => Err(MuseAuthError::PaymentRequired),
586 None => Err(MuseAuthError::EmptyApiKey),
587 }
588}
589
590pub fn credentials_from_mint(
592 oauth_access_token: &str,
593 minted: &MintedKey,
594 now_ms: u64,
595) -> Result<OAuthCredentials, MuseAuthError> {
596 if oauth_access_token.trim().is_empty() {
597 return Err(MuseAuthError::EmptyAccessToken);
598 }
599 if minted.api_key.trim().is_empty() {
600 return Err(MuseAuthError::EmptyApiKey);
601 }
602 Ok(OAuthCredentials {
603 auth_type: "oauth".into(),
604 refresh: oauth_access_token.to_string(),
605 access: minted.api_key.clone(),
606 expires: now_ms.saturating_add(API_KEY_NOMINAL_LIFETIME_MS),
607 account_id: None,
608 })
609}
610
611pub fn apply_slow_down(interval_secs: u64) -> u64 {
612 interval_secs
613 .saturating_add(SLOW_DOWN_STEP_SECS)
614 .min(MAX_POLL_INTERVAL_SECS)
615}
616
617pub fn api_key_from_env() -> Option<String> {
619 std::env::var(API_KEY_ENV)
620 .ok()
621 .map(|key| key.trim().to_string())
622 .filter(|key| !key.is_empty())
623}
624
625pub fn env_api_key_present() -> bool {
629 api_key_from_env().is_some()
630}
631
632pub async fn read_body_capped(
636 response: reqwest::Response,
637 cap: usize,
638) -> Result<String, MuseAuthError> {
639 use futures::StreamExt;
640 let mut buf = Vec::new();
641 let mut stream = response.bytes_stream();
642 while let Some(chunk) = stream.next().await {
643 let chunk = chunk.map_err(|_| MuseAuthError::Transport)?;
644 if buf.len().saturating_add(chunk.len()) > cap {
645 return Err(MuseAuthError::ResponseTooLarge);
646 }
647 buf.extend_from_slice(&chunk);
648 }
649 String::from_utf8(buf).map_err(|_| MuseAuthError::Transport)
650}
651
652pub async fn start_device_authorization<H, C>(
656 http: &H,
657 clock: &C,
658) -> Result<DeviceAuthorization, MuseAuthError>
659where
660 H: MuseHttp,
661 C: MuseClock,
662{
663 validate_device_endpoint(DEVICE_AUTH_URL)?;
664 let resp = http
665 .post_form(DEVICE_AUTH_URL, &[("client_id", CLIENT_ID)])
666 .await?;
667 if !(200..300).contains(&resp.status) {
668 return Err(MuseAuthError::HttpStatus(resp.status));
669 }
670 parse_device_code_response(&resp.body, clock.now_millis())
671}
672
673pub async fn poll_device_token<H: MuseHttp>(
675 http: &H,
676 device_code: &str,
677) -> Result<DevicePollOutcome, MuseAuthError> {
678 validate_device_endpoint(TOKEN_URL)?;
679 let resp = http
680 .post_form(
681 TOKEN_URL,
682 &[
683 ("grant_type", DEVICE_GRANT_TYPE),
684 ("device_code", device_code),
685 ("client_id", CLIENT_ID),
686 ],
687 )
688 .await?;
689 let parsed = parse_device_poll_response(&resp.body);
690 if (200..300).contains(&resp.status) {
691 return parsed;
692 }
693 match parsed {
696 Ok(
697 outcome @ (DevicePollOutcome::Pending
698 | DevicePollOutcome::SlowDown
699 | DevicePollOutcome::Denied
700 | DevicePollOutcome::Expired),
701 ) => Ok(outcome),
702 _ => Err(MuseAuthError::HttpStatus(resp.status)),
703 }
704}
705
706pub async fn wait_for_device_authorization<H, C>(
709 http: &H,
710 clock: &C,
711 authz: &DeviceAuthorization,
712 cancel: &AtomicBool,
713) -> Result<String, MuseAuthError>
714where
715 H: MuseHttp,
716 C: MuseClock,
717{
718 let deadline_ms = authz
719 .issued_at_ms
720 .saturating_add(authz.expires_in_secs.saturating_mul(1000));
721 let mut interval_secs = authz.interval_secs.clamp(1, MAX_POLL_INTERVAL_SECS);
722 loop {
723 if cancel.load(Ordering::SeqCst) {
724 return Err(MuseAuthError::Cancelled);
725 }
726 if clock.now_millis() >= deadline_ms {
727 return Err(MuseAuthError::Expired);
728 }
729 clock
730 .sleep_cancellable(Duration::from_secs(interval_secs), cancel)
731 .await?;
732 if clock.now_millis() >= deadline_ms {
733 return Err(MuseAuthError::Expired);
734 }
735 match poll_device_token(http, &authz.device_code).await? {
736 DevicePollOutcome::Pending => continue,
737 DevicePollOutcome::SlowDown => interval_secs = apply_slow_down(interval_secs),
738 DevicePollOutcome::Authorized { access_token } => return Ok(access_token),
739 DevicePollOutcome::Denied => return Err(MuseAuthError::AccessDenied),
740 DevicePollOutcome::Expired => return Err(MuseAuthError::Expired),
741 DevicePollOutcome::OtherError => return Err(MuseAuthError::InvalidTokenResponse),
742 }
743 }
744}
745
746pub async fn mint_api_key<H: MuseHttp>(
751 http: &H,
752 oauth_access_token: &str,
753) -> Result<MintedKey, MuseAuthError> {
754 if oauth_access_token.trim().is_empty() {
755 return Err(MuseAuthError::EmptyAccessToken);
756 }
757 validate_mint_endpoint(MINT_URL)?;
758 let resp = http
759 .post_json_bearer(
760 MINT_URL,
761 oauth_access_token,
762 mint_request_headers(),
763 MINT_BODY,
764 )
765 .await?;
766 parse_mint_response(resp.status, &resp.body)
767}
768
769pub async fn mint_credentials<H, C>(
771 http: &H,
772 clock: &C,
773 oauth_access_token: &str,
774) -> Result<(OAuthCredentials, MintedKey), MuseAuthError>
775where
776 H: MuseHttp,
777 C: MuseClock,
778{
779 let minted = mint_api_key(http, oauth_access_token).await?;
780 let creds = credentials_from_mint(oauth_access_token, &minted, clock.now_millis())?;
781 Ok((creds, minted))
782}
783
784pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
787
788pub struct LoginHooks<'a, B> {
789 pub browser: &'a B,
790 pub on_user_code: Option<UserCodeHook<'a>>,
793}
794
795pub async fn login_with_into<H, C, B>(
798 http: &H,
799 clock: &C,
800 hooks: LoginHooks<'_, B>,
801 cancel: &AtomicBool,
802 persist_key: Option<&str>,
803) -> Result<OAuthCredentials, MuseAuthError>
804where
805 H: MuseHttp,
806 C: MuseClock,
807 B: MuseBrowser,
808{
809 let authz = start_device_authorization(http, clock).await?;
810 if let Some(cb) = hooks.on_user_code {
811 cb(&authz.user_code, authz.browser_uri());
812 } else {
813 eprintln!("\n\x1b[1mMeta Muse device login\x1b[0m");
814 eprintln!(" Enter code: \x1b[36m{}\x1b[0m", authz.user_code);
815 eprintln!(" At: \x1b[36m{}\x1b[0m\n", authz.verification_uri);
816 }
817 let _ = hooks.browser.open(authz.browser_uri());
818
819 let oauth_access_token = wait_for_device_authorization(http, clock, &authz, cancel).await?;
820 let (creds, minted) = mint_credentials(http, clock, &oauth_access_token).await?;
821 if hooks.on_user_code.is_none() {
822 if let Some(email) = &minted.user_email {
823 eprintln!(" Signed in to Meta as \x1b[36m{email}\x1b[0m");
824 }
825 if minted.subs_active == Some(false) {
826 eprintln!(
827 " Note: no active Muse plan on this account; usage may be limited \
828 (manage it at {ONBOARDING_URL})."
829 );
830 }
831 }
832 if let Some(key) = persist_key {
833 super::storage::save_provider_auth_typed(key, &creds).map_err(MuseAuthError::from_store)?;
834 }
835 Ok(creds)
836}
837
838pub async fn login() -> Result<OAuthCredentials, String> {
840 login_into(Some(PROVIDER)).await
841}
842
843pub async fn login_into(persist_key: Option<&str>) -> Result<OAuthCredentials, String> {
846 let http = ProductionHttp::new().map_err(|e| e.to_string())?;
847 let cancel = AtomicBool::new(false);
848 login_with_into(
849 &http,
850 &ProductionClock,
851 LoginHooks {
852 browser: &ProductionBrowser,
853 on_user_code: None,
854 },
855 &cancel,
856 persist_key,
857 )
858 .await
859 .map_err(|e| e.to_string())
860}
861
862pub async fn refresh_token(
866 _client: &Client,
867 oauth_access_token: &str,
868) -> Result<OAuthCredentials, super::RefreshFailure> {
869 let http = ProductionHttp::new().map_err(|e| e.refresh_failure())?;
870 mint_credentials(&http, &ProductionClock, oauth_access_token)
871 .await
872 .map(|(creds, _)| creds)
873 .map_err(|e| e.refresh_failure())
874}
875
876pub(crate) async fn refresh_token_via_test_mint(
881 mint_url: &str,
882 oauth_access_token: &str,
883) -> Result<OAuthCredentials, super::RefreshFailure> {
884 let parsed = Url::parse(mint_url)
885 .map_err(|_| MuseAuthError::UntrustedEndpoint.refresh_failure())?;
886 let loopback = matches!(parsed.host_str(), Some("127.0.0.1") | Some("localhost"));
887 if parsed.scheme() != "http" || !loopback {
888 return Err(MuseAuthError::UntrustedEndpoint.refresh_failure());
889 }
890 let http = LoopbackMintHttp {
891 inner: ProductionHttp::new().map_err(|e| e.refresh_failure())?,
892 mint_url: mint_url.to_string(),
893 };
894 mint_credentials(&http, &ProductionClock, oauth_access_token)
895 .await
896 .map(|(creds, _)| creds)
897 .map_err(|e| e.refresh_failure())
898}
899
900struct LoopbackMintHttp {
901 inner: ProductionHttp,
902 mint_url: String,
903}
904
905#[async_trait]
906impl MuseHttp for LoopbackMintHttp {
907 async fn post_form(&self, _: &str, _: &[(&str, &str)]) -> Result<MuseHttpResponse, MuseAuthError> {
908 Err(MuseAuthError::UntrustedEndpoint)
909 }
910
911 async fn post_json_bearer(
912 &self,
913 url: &str,
914 bearer: &str,
915 headers: &[(&str, &str)],
916 body: &str,
917 ) -> Result<MuseHttpResponse, MuseAuthError> {
918 validate_mint_endpoint(url)?;
919 let mut req = self
920 .inner
921 .client
922 .post(&self.mint_url)
923 .header("Authorization", format!("Bearer {bearer}"));
924 for (name, value) in headers {
925 if name.eq_ignore_ascii_case("authorization") {
926 continue;
927 }
928 req = req.header(*name, *value);
929 }
930 let response = req
931 .body(body.to_string())
932 .send()
933 .await
934 .map_err(|_| MuseAuthError::Transport)?;
935 ProductionHttp::finish(response).await
936 }
937}
938
939struct ProductionHttp {
942 client: Client,
944}
945
946impl ProductionHttp {
947 fn new() -> Result<Self, MuseAuthError> {
948 let client = Client::builder()
949 .redirect(reqwest::redirect::Policy::none())
950 .connect_timeout(CONNECT_TIMEOUT)
951 .timeout(REQUEST_TIMEOUT)
952 .build()
953 .map_err(|_| MuseAuthError::Transport)?;
954 Ok(Self { client })
955 }
956
957 async fn finish(response: reqwest::Response) -> Result<MuseHttpResponse, MuseAuthError> {
958 let status = response.status().as_u16();
959 if (300..400).contains(&status) {
961 return Err(MuseAuthError::UntrustedEndpoint);
962 }
963 let body = read_body_capped(response, MAX_RESPONSE_BODY_BYTES).await?;
964 Ok(MuseHttpResponse { status, body })
965 }
966}
967
968#[async_trait]
969impl MuseHttp for ProductionHttp {
970 async fn post_form(
971 &self,
972 url: &str,
973 form: &[(&str, &str)],
974 ) -> Result<MuseHttpResponse, MuseAuthError> {
975 validate_device_endpoint(url)?;
976 let response = self
977 .client
978 .post(url)
979 .header("Accept", "application/json")
980 .header("User-Agent", USER_AGENT)
981 .form(form)
982 .send()
983 .await
984 .map_err(|_| MuseAuthError::Transport)?;
985 Self::finish(response).await
986 }
987
988 async fn post_json_bearer(
989 &self,
990 url: &str,
991 bearer: &str,
992 headers: &[(&str, &str)],
993 body: &str,
994 ) -> Result<MuseHttpResponse, MuseAuthError> {
995 validate_mint_endpoint(url)?;
997 let mut req = self
998 .client
999 .post(url)
1000 .header("Authorization", format!("Bearer {bearer}"));
1001 for (name, value) in headers {
1002 if name.eq_ignore_ascii_case("authorization") {
1004 continue;
1005 }
1006 req = req.header(*name, *value);
1007 }
1008 let response = req
1009 .body(body.to_string())
1010 .send()
1011 .await
1012 .map_err(|_| MuseAuthError::Transport)?;
1013 Self::finish(response).await
1014 }
1015}
1016
1017struct ProductionClock;
1018
1019#[async_trait]
1020impl MuseClock for ProductionClock {
1021 fn now_millis(&self) -> u64 {
1022 now_millis()
1023 }
1024
1025 async fn sleep_cancellable(
1026 &self,
1027 duration: Duration,
1028 cancel: &AtomicBool,
1029 ) -> Result<(), MuseAuthError> {
1030 let mut remaining = duration;
1031 while remaining > Duration::ZERO {
1032 if cancel.load(Ordering::SeqCst) {
1033 return Err(MuseAuthError::Cancelled);
1034 }
1035 let chunk = remaining.min(CANCEL_POLL_QUANTUM);
1036 tokio::time::sleep(chunk).await;
1037 remaining = remaining.saturating_sub(chunk);
1038 }
1039 if cancel.load(Ordering::SeqCst) {
1040 return Err(MuseAuthError::Cancelled);
1041 }
1042 Ok(())
1043 }
1044}
1045
1046struct ProductionBrowser;
1047
1048impl MuseBrowser for ProductionBrowser {
1049 fn open(&self, url: &str) -> Result<(), MuseAuthError> {
1050 validate_verification_uri(url)?;
1051 let _ = open_browser(url);
1052 Ok(())
1053 }
1054}
1055
1056#[cfg(test)]
1059mod tests {
1060 use super::*;
1061
1062 #[test]
1065 fn payment_required_text_does_not_send_the_user_to_login() {
1066 let text = MuseAuthError::PaymentRequired.to_string();
1067 assert!(text.contains(ONBOARDING_URL), "{text}");
1068 assert!(text.ends_with("then retry"), "{text}");
1069 assert!(!text.contains("synaps login"), "{text}");
1070 }
1071
1072 #[test]
1075 fn login_save_on_a_corrupt_store_says_how_to_fix_it() {
1076 use super::super::storage::StoreError;
1077 let msg = "/x/auth.json is not valid JSON; mv '/x/auth.json' '/x/auth.json.broken-1'";
1078 let err = MuseAuthError::from_store(StoreError::Corrupt(msg.into()));
1079 assert_eq!(err.to_string(), msg);
1080 assert_eq!(
1081 MuseAuthError::from_store(StoreError::Failed("disk full".into())).to_string(),
1082 "credential persistence failed"
1083 );
1084 }
1085 use std::sync::Mutex;
1086
1087 #[tokio::test]
1089 async fn test_mint_seam_refuses_non_loopback_urls() {
1090 for url in [
1091 "https://api.meta.ai/muse-code/key",
1092 "http://10.0.0.1/muse-code/key",
1093 "https://127.0.0.1/muse-code/key",
1094 "http://evil.example/muse-code/key",
1095 "not a url",
1096 ] {
1097 let err = refresh_token_via_test_mint(url, "OAUTH").await.unwrap_err();
1098 assert_eq!(err.message(), "untrusted endpoint", "{url}");
1099 }
1100 }
1101
1102 #[derive(Default)]
1103 struct FakeClock {
1104 now: Mutex<u64>,
1105 sleeps: Mutex<Vec<u64>>,
1106 }
1107
1108 impl FakeClock {
1109 fn at(now: u64) -> Self {
1110 Self {
1111 now: Mutex::new(now),
1112 sleeps: Mutex::new(Vec::new()),
1113 }
1114 }
1115 }
1116
1117 #[async_trait]
1118 impl MuseClock for FakeClock {
1119 fn now_millis(&self) -> u64 {
1120 *self.now.lock().unwrap()
1121 }
1122 async fn sleep_cancellable(
1123 &self,
1124 duration: Duration,
1125 cancel: &AtomicBool,
1126 ) -> Result<(), MuseAuthError> {
1127 if cancel.load(Ordering::SeqCst) {
1128 return Err(MuseAuthError::Cancelled);
1129 }
1130 self.sleeps.lock().unwrap().push(duration.as_secs());
1131 *self.now.lock().unwrap() += duration.as_millis() as u64;
1132 Ok(())
1133 }
1134 }
1135
1136 type Recorded = (String, Option<String>, Vec<(String, String)>, String);
1137
1138 #[derive(Default)]
1139 struct FakeHttp {
1140 queue: Mutex<Vec<(u16, String)>>,
1141 calls: Mutex<Vec<Recorded>>,
1143 }
1144
1145 impl FakeHttp {
1146 fn script(responses: &[(u16, &str)]) -> Self {
1147 let http = Self::default();
1148 *http.queue.lock().unwrap() = responses
1149 .iter()
1150 .rev()
1151 .map(|(s, b)| (*s, b.to_string()))
1152 .collect();
1153 http
1154 }
1155 fn next(&self) -> Result<MuseHttpResponse, MuseAuthError> {
1156 let (status, body) = self
1157 .queue
1158 .lock()
1159 .unwrap()
1160 .pop()
1161 .expect("unscripted request");
1162 Ok(MuseHttpResponse { status, body })
1163 }
1164 }
1165
1166 #[async_trait]
1167 impl MuseHttp for FakeHttp {
1168 async fn post_form(
1169 &self,
1170 url: &str,
1171 form: &[(&str, &str)],
1172 ) -> Result<MuseHttpResponse, MuseAuthError> {
1173 validate_device_endpoint(url)?;
1174 self.calls.lock().unwrap().push((
1175 url.into(),
1176 None,
1177 form.iter()
1178 .map(|(k, v)| (k.to_string(), v.to_string()))
1179 .collect(),
1180 String::new(),
1181 ));
1182 self.next()
1183 }
1184 async fn post_json_bearer(
1185 &self,
1186 url: &str,
1187 bearer: &str,
1188 headers: &[(&str, &str)],
1189 body: &str,
1190 ) -> Result<MuseHttpResponse, MuseAuthError> {
1191 validate_mint_endpoint(url)?;
1192 self.calls.lock().unwrap().push((
1193 url.into(),
1194 Some(bearer.into()),
1195 headers
1196 .iter()
1197 .map(|(k, v)| (k.to_string(), v.to_string()))
1198 .collect(),
1199 body.into(),
1200 ));
1201 self.next()
1202 }
1203 }
1204
1205 #[derive(Default)]
1206 struct FakeBrowser(Mutex<Vec<String>>);
1207 impl MuseBrowser for FakeBrowser {
1208 fn open(&self, url: &str) -> Result<(), MuseAuthError> {
1209 validate_verification_uri(url)?;
1210 self.0.lock().unwrap().push(url.into());
1211 Ok(())
1212 }
1213 }
1214
1215 const DEVICE_OK: &str = r#"{"device_code":"DEV-SECRET","user_code":"ABCD-EFGH",
1216 "verification_uri":"https://auth.meta.com/device",
1217 "verification_uri_complete":"https://auth.meta.com/device?user_code=ABCD-EFGH",
1218 "expires_in":600,"interval":2}"#;
1219 const PENDING: &str = r#"{"error":"authorization_pending"}"#;
1220 const SLOW: &str = r#"{"error":"slow_down"}"#;
1221 const TOKEN_OK: &str = r#"{"access_token":"OAUTH-AT","token_type":"Bearer",
1222 "expires_in":3600,"refresh_token":"OAUTH-RT","scope":"openid"}"#;
1223 const MINT_OK: &str = r#"{"api_key":"MODEL-KEY-1","base_url":"https://api.meta.ai/v1",
1224 "user_full_name":"Test User","user_email":"t@example.com","is_subs_active":true}"#;
1225
1226 #[test]
1227 fn constants_are_pinned_https_meta_hosts() {
1228 for url in [DEVICE_AUTH_URL, TOKEN_URL] {
1229 assert!(validate_device_endpoint(url).is_ok(), "{url}");
1230 assert!(
1231 url.ends_with('/'),
1232 "official CLI sends trailing slash: {url}"
1233 );
1234 }
1235 assert!(validate_mint_endpoint(MINT_URL).is_ok());
1236 assert_eq!(CLIENT_ID, "1031625952748946");
1237 assert_eq!(API_BASE_URL, format!("{API_ORIGIN}/v1"));
1238 assert_eq!(PROVIDER, "muse");
1239 }
1240
1241 #[test]
1242 fn rejects_non_pinned_endpoints() {
1243 for url in [
1244 "http://auth.meta.com/oidc/device/token/",
1245 "https://auth.meta.com.evil.test/oidc/device/token/",
1246 "https://auth.meta.com/oidc/device/token",
1247 "https://evil.test/oidc/device/token/",
1248 ] {
1249 assert_eq!(
1250 validate_device_endpoint(url),
1251 Err(MuseAuthError::UntrustedEndpoint),
1252 "{url}"
1253 );
1254 }
1255 for url in [
1256 "http://api.meta.ai/muse-code/key",
1257 "https://api.meta.ai/muse-code/key?x=1",
1258 "https://api.meta.ai.evil.test/muse-code/key",
1259 "https://api.meta.ai/muse-code/logout",
1260 ] {
1261 assert_eq!(
1262 validate_mint_endpoint(url),
1263 Err(MuseAuthError::UntrustedEndpoint),
1264 "{url}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn verification_uri_must_be_plain_https_on_meta_hosts() {
1271 for ok in [
1272 "https://auth.meta.com/device",
1273 "https://www.meta.com/device?user_code=X",
1274 "https://meta.ai/activate",
1275 ] {
1276 assert!(validate_verification_uri(ok).is_ok(), "{ok}");
1277 }
1278 for bad in [
1279 "http://auth.meta.com/device",
1280 "https://user@auth.meta.com/device",
1281 "https://auth.meta.com:8443/device",
1282 "https://auth.meta.com/device#frag",
1283 "https://notmeta.com/device",
1284 "https://meta.com.evil.test/device",
1285 "javascript:alert(1)",
1286 ] {
1287 assert!(validate_verification_uri(bad).is_err(), "{bad}");
1288 }
1289 }
1290
1291 #[test]
1292 fn device_code_parse_defaults_and_drops_foreign_complete_uri() {
1293 let authz = parse_device_code_response(DEVICE_OK, 1_000).unwrap();
1294 assert_eq!(authz.user_code, "ABCD-EFGH");
1295 assert_eq!(authz.interval_secs, 2);
1296 assert_eq!(authz.expires_in_secs, 600);
1297 assert_eq!(
1298 authz.browser_uri(),
1299 "https://auth.meta.com/device?user_code=ABCD-EFGH"
1300 );
1301
1302 let foreign = r#"{"device_code":"d","user_code":"u",
1303 "verification_uri":"https://auth.meta.com/device",
1304 "verification_uri_complete":"https://evil.test/device"}"#;
1305 let authz = parse_device_code_response(foreign, 0).unwrap();
1306 assert_eq!(authz.browser_uri(), "https://auth.meta.com/device");
1307 assert_eq!(authz.expires_in_secs, DEFAULT_DEVICE_EXPIRES_IN_SECS);
1308 assert_eq!(authz.interval_secs, DEFAULT_POLL_INTERVAL_SECS);
1309
1310 let bad_page = r#"{"device_code":"d","user_code":"u",
1311 "verification_uri":"https://evil.test/device"}"#;
1312 assert_eq!(
1313 parse_device_code_response(bad_page, 0).unwrap_err(),
1314 MuseAuthError::UntrustedEndpoint
1315 );
1316 let empty = r#"{"device_code":" ","user_code":"u",
1317 "verification_uri":"https://auth.meta.com/device"}"#;
1318 assert_eq!(
1319 parse_device_code_response(empty, 0).unwrap_err(),
1320 MuseAuthError::InvalidDeviceResponse
1321 );
1322 }
1323
1324 #[test]
1325 fn poll_outcomes_follow_rfc8628() {
1326 assert_eq!(
1327 parse_device_poll_response(PENDING).unwrap(),
1328 DevicePollOutcome::Pending
1329 );
1330 assert_eq!(
1331 parse_device_poll_response(SLOW).unwrap(),
1332 DevicePollOutcome::SlowDown
1333 );
1334 assert_eq!(
1335 parse_device_poll_response(r#"{"error":"access_denied"}"#).unwrap(),
1336 DevicePollOutcome::Denied
1337 );
1338 assert_eq!(
1339 parse_device_poll_response(r#"{"error":"expired_token"}"#).unwrap(),
1340 DevicePollOutcome::Expired
1341 );
1342 assert_eq!(
1343 parse_device_poll_response(TOKEN_OK).unwrap(),
1344 DevicePollOutcome::Authorized {
1345 access_token: "OAUTH-AT".into()
1346 }
1347 );
1348 assert_eq!(
1349 parse_device_poll_response(r#"{"access_token":" "}"#).unwrap_err(),
1350 MuseAuthError::EmptyAccessToken
1351 );
1352 }
1353
1354 #[test]
1355 fn slow_down_is_bounded() {
1356 let mut interval = 5;
1357 for _ in 0..20 {
1358 interval = apply_slow_down(interval);
1359 }
1360 assert_eq!(interval, MAX_POLL_INTERVAL_SECS);
1361 }
1362
1363 #[test]
1364 fn mint_classification_is_static_and_secret_free() {
1365 let minted = parse_mint_response(200, MINT_OK).unwrap();
1366 assert_eq!(minted.api_key, "MODEL-KEY-1");
1367 assert_eq!(minted.user_email.as_deref(), Some("t@example.com"));
1368 assert!(!format!("{minted:?}").contains("MODEL-KEY-1"));
1369
1370 let onboarding = r#"{"error":{"code":"MODEL_API__ONBOARDING_REQUIRED",
1371 "message":"<html>echo OAUTH-AT</html>"}}"#;
1372 for status in [200, 400, 403] {
1373 let err = parse_mint_response(status, onboarding).unwrap_err();
1374 assert_eq!(err, MuseAuthError::OnboardingRequired);
1375 assert!(err.to_string().contains("dev.meta.ai"));
1376 assert!(!err.to_string().contains("OAUTH-AT"));
1377 }
1378 assert_eq!(
1379 parse_mint_response(401, r#"{"error":"bad token OAUTH-AT"}"#).unwrap_err(),
1380 MuseAuthError::LoginRejected(401)
1381 );
1382 assert_eq!(
1383 parse_mint_response(500, "boom").unwrap_err(),
1384 MuseAuthError::HttpStatus(500)
1385 );
1386 assert_eq!(
1387 parse_mint_response(200, r#"{"api_key":"","require_payment":true}"#).unwrap_err(),
1388 MuseAuthError::PaymentRequired
1389 );
1390 assert_eq!(
1391 parse_mint_response(200, r#"{"api_key":""}"#).unwrap_err(),
1392 MuseAuthError::EmptyApiKey
1393 );
1394 assert_eq!(
1395 parse_mint_response(200, "not json").unwrap_err(),
1396 MuseAuthError::InvalidMintResponse
1397 );
1398 }
1399
1400 #[test]
1401 fn credentials_map_oauth_to_refresh_and_key_to_access() {
1402 let minted = parse_mint_response(200, MINT_OK).unwrap();
1403 let creds = credentials_from_mint("OAUTH-AT", &minted, 1_000).unwrap();
1404 assert_eq!(creds.refresh, "OAUTH-AT");
1405 assert_eq!(creds.access, "MODEL-KEY-1");
1406 assert_eq!(creds.auth_type, "oauth");
1407 assert_eq!(creds.expires, 1_000 + API_KEY_NOMINAL_LIFETIME_MS);
1408 assert!(creds.account_id.is_none());
1409 assert!(credentials_from_mint(" ", &minted, 0).is_err());
1410 }
1411
1412 #[tokio::test]
1413 async fn full_login_polls_then_mints_without_persisting() {
1414 let http = FakeHttp::script(&[
1415 (200, DEVICE_OK),
1416 (400, PENDING),
1417 (400, SLOW),
1418 (200, TOKEN_OK),
1419 (200, MINT_OK),
1420 ]);
1421 let clock = FakeClock::at(1_000);
1422 let browser = FakeBrowser::default();
1423 let seen = Mutex::new(Vec::new());
1424 let hook = |code: &str, uri: &str| seen.lock().unwrap().push((code.into(), uri.into()));
1425 let creds = login_with_into(
1426 &http,
1427 &clock,
1428 LoginHooks {
1429 browser: &browser,
1430 on_user_code: Some(&hook),
1431 },
1432 &AtomicBool::new(false),
1433 None,
1434 )
1435 .await
1436 .unwrap();
1437 assert_eq!(creds.access, "MODEL-KEY-1");
1438 assert_eq!(creds.refresh, "OAUTH-AT");
1439 assert_eq!(*clock.sleeps.lock().unwrap(), vec![2, 2, 7]);
1441 assert_eq!(
1442 seen.lock().unwrap()[0],
1443 (
1444 "ABCD-EFGH".to_string(),
1445 "https://auth.meta.com/device?user_code=ABCD-EFGH".to_string()
1446 )
1447 );
1448 assert_eq!(browser.0.lock().unwrap().len(), 1);
1449
1450 let calls = http.calls.lock().unwrap();
1451 assert_eq!(calls.len(), 5);
1452 assert_eq!(calls[0].0, DEVICE_AUTH_URL);
1454 assert_eq!(
1455 calls[0].2,
1456 vec![("client_id".to_string(), CLIENT_ID.to_string())]
1457 );
1458 assert_eq!(calls[1].0, TOKEN_URL);
1460 assert!(calls[1]
1461 .2
1462 .contains(&("grant_type".to_string(), DEVICE_GRANT_TYPE.to_string())));
1463 assert!(calls[1]
1464 .2
1465 .contains(&("device_code".to_string(), "DEV-SECRET".to_string())));
1466 let mint = &calls[4];
1468 assert_eq!(mint.0, MINT_URL);
1469 assert_eq!(mint.1.as_deref(), Some("OAUTH-AT"));
1470 assert_eq!(mint.3, MINT_BODY);
1471 assert!(mint
1472 .2
1473 .contains(&("x-api-version".to_string(), "1.0.0".to_string())));
1474 assert!(!mint
1475 .2
1476 .iter()
1477 .any(|(k, _)| k.eq_ignore_ascii_case("authorization")));
1478 }
1479
1480 #[tokio::test]
1481 async fn denial_expiry_and_cancel_stop_polling() {
1482 let http = FakeHttp::script(&[(400, r#"{"error":"access_denied"}"#)]);
1483 let authz = parse_device_code_response(DEVICE_OK, 0).unwrap();
1484 let err = wait_for_device_authorization(
1485 &http,
1486 &FakeClock::at(0),
1487 &authz,
1488 &AtomicBool::new(false),
1489 )
1490 .await
1491 .unwrap_err();
1492 assert_eq!(err, MuseAuthError::AccessDenied);
1493
1494 let http = FakeHttp::script(&[]);
1496 let err = wait_for_device_authorization(
1497 &http,
1498 &FakeClock::at(600_001),
1499 &authz,
1500 &AtomicBool::new(false),
1501 )
1502 .await
1503 .unwrap_err();
1504 assert_eq!(err, MuseAuthError::Expired);
1505
1506 let err =
1507 wait_for_device_authorization(&http, &FakeClock::at(0), &authz, &AtomicBool::new(true))
1508 .await
1509 .unwrap_err();
1510 assert_eq!(err, MuseAuthError::Cancelled);
1511 }
1512
1513 #[tokio::test]
1514 async fn login_fails_closed_when_mint_requires_onboarding() {
1515 let http = FakeHttp::script(&[
1516 (200, DEVICE_OK),
1517 (200, TOKEN_OK),
1518 (403, r#"{"code":"MODEL_API__ONBOARDING_REQUIRED"}"#),
1519 ]);
1520 let err = login_with_into(
1521 &http,
1522 &FakeClock::at(0),
1523 LoginHooks {
1524 browser: &FakeBrowser::default(),
1525 on_user_code: Some(&|_, _| {}),
1526 },
1527 &AtomicBool::new(false),
1528 None,
1529 )
1530 .await
1531 .unwrap_err();
1532 assert_eq!(err, MuseAuthError::OnboardingRequired);
1533 }
1534
1535 #[tokio::test]
1536 async fn remint_uses_stored_oauth_token_and_keeps_it_as_refresh() {
1537 let http = FakeHttp::script(&[(200, r#"{"api_key":"MODEL-KEY-2"}"#)]);
1538 let (creds, _) = mint_credentials(&http, &FakeClock::at(5), "OAUTH-AT")
1539 .await
1540 .unwrap();
1541 assert_eq!(creds.access, "MODEL-KEY-2");
1542 assert_eq!(creds.refresh, "OAUTH-AT");
1544 let http = FakeHttp::script(&[(401, "{}")]);
1545 assert_eq!(
1546 mint_credentials(&http, &FakeClock::at(5), "OAUTH-AT")
1547 .await
1548 .unwrap_err(),
1549 MuseAuthError::LoginRejected(401)
1550 );
1551 }
1552
1553 #[test]
1554 fn errors_debug_display_are_secret_free() {
1555 for err in [
1556 MuseAuthError::LoginRejected(401),
1557 MuseAuthError::HttpStatus(500),
1558 MuseAuthError::OnboardingRequired,
1559 MuseAuthError::PaymentRequired,
1560 MuseAuthError::EmptyApiKey,
1561 ] {
1562 let shown = format!("{err} {err:?}");
1563 assert!(!shown.contains("OAUTH-AT") && !shown.contains("MODEL-KEY"));
1564 }
1565 }
1566
1567 #[test]
1568 fn production_http_builds_with_no_redirects() {
1569 assert!(ProductionHttp::new().is_ok());
1570 assert_eq!(MAX_RESPONSE_BODY_BYTES, 64 * 1024);
1571 assert!(USER_AGENT.starts_with("SynapsCLI/"));
1572 }
1573
1574 #[test]
1575 fn device_poll_outcome_debug_redacts_token() {
1576 let o = DevicePollOutcome::Authorized {
1577 access_token: "oauth-secret-123".into(),
1578 };
1579 let dbg = format!("{o:?}");
1580 assert!(!dbg.contains("oauth-secret-123"), "{dbg}");
1581 assert!(
1582 dbg.contains("Authorized") && dbg.contains("REDACTED"),
1583 "{dbg}"
1584 );
1585 assert_eq!(format!("{:?}", DevicePollOutcome::Pending), "Pending");
1586 }
1587
1588 #[test]
1589 fn device_flow_debug_redacts_secrets() {
1590 let r = MuseHttpResponse {
1591 status: 200,
1592 body: "{\"access_token\":\"body-secret\"}".into(),
1593 };
1594 let dbg = format!("{r:?}");
1595 assert!(!dbg.contains("body-secret") && dbg.contains("200"), "{dbg}");
1596
1597 let a = DeviceAuthorization {
1598 device_code: "device-secret".into(),
1599 user_code: "ABCD-EFGH".into(),
1600 verification_uri: "https://example.invalid".into(),
1601 verification_uri_complete: None,
1602 expires_in_secs: 600,
1603 interval_secs: 5,
1604 issued_at_ms: 0,
1605 };
1606 let dbg = format!("{a:?}");
1607 assert!(
1608 !dbg.contains("device-secret") && dbg.contains("ABCD-EFGH"),
1609 "{dbg}"
1610 );
1611
1612 let c = DeviceCodeJson {
1613 device_code: "code-json-secret".into(),
1614 user_code: "WXYZ-1234".into(),
1615 verification_uri: "https://example.invalid".into(),
1616 verification_uri_complete: None,
1617 expires_in: Some(600),
1618 interval: Some(5),
1619 };
1620 let dbg = format!("{c:?}");
1621 assert!(
1622 !dbg.contains("code-json-secret") && dbg.contains("WXYZ-1234"),
1623 "{dbg}"
1624 );
1625
1626 let t = DeviceTokenJson {
1627 access_token: Some("token-secret".into()),
1628 error: Some("slow_down".into()),
1629 };
1630 let dbg = format!("{t:?}");
1631 assert!(
1632 !dbg.contains("token-secret") && dbg.contains("slow_down"),
1633 "{dbg}"
1634 );
1635 }
1636}