Skip to main content

agent_core/core/auth/
muse.rs

1//! Meta Muse Code OAuth (RFC 8628 device authorization + Model API key mint).
2//!
3//! Experimental. Flow verified against the official Muse Code CLI
4//! (`muse 1.4.6-R6000.1`, installed via `dev.meta.ai/install.sh`) by driving
5//! it against local mock servers and reading its embedded constants. The
6//! endpoints are not published as a stable third-party API; do not describe
7//! them as officially supported.
8//!
9//! Flow shape (differences from Kimi Code / GitHub Copilot):
10//! - Device authorization at `auth.meta.com/oidc/device/{authorization,token}/`
11//!   with a public client id, no PKCE and no `scope` parameter.
12//! - The OAuth access token is NOT the inference bearer. It is exchanged once
13//!   at `POST api.meta.ai/muse-code/key` (`{"onboard":false}`) for a Model
14//!   API key, and that key is the `Authorization: Bearer` for
15//!   `api.meta.ai/v1/responses` (OpenAI Responses wire). An API key created
16//!   at dev.meta.ai is the same kind of credential (`META_API_KEY`).
17//! - The CLI never refreshes the OAuth token and never proactively re-mints:
18//!   the key is used until the API answers 401, then re-minted ONCE with the
19//!   stored OAuth access token and the request retried once. A mint 401 means
20//!   the login itself is dead — the user must log in again. We mirror this.
21//!
22//! Credential mapping:
23//! - `OAuthCredentials.refresh` = Meta OAuth access token (broker-owned; used
24//!   only to re-mint; never vended, logged, or sent to the inference host)
25//! - `OAuthCredentials.access`  = minted Model API key (the inference bearer)
26//! - `OAuthCredentials.expires` = nominal re-validation horizon (see
27//!   [`API_KEY_NOMINAL_LIFETIME_MS`]); real invalidation is 401-driven.
28//!
29//! The OAuth refresh token is deliberately not stored: the official client
30//! never uses it, so keeping it would only widen what a leaked auth.json
31//! exposes.
32
33use 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
46// ── Public constants (evidence: muse 1.4.6-R6000.1 binary + mock capture) ────
47
48/// Canonical storage / broker / model-prefix id.
49pub const PROVIDER: &str = "muse";
50
51/// Public native-client id shipped in the official Muse Code CLI. Not a
52/// client secret.
53pub const CLIENT_ID: &str = "1031625952748946";
54
55/// Meta OAuth host pinned by the official CLI (`TBH_AUTH_BASE_URL` default).
56pub const OAUTH_HOST: &str = "auth.meta.com";
57
58/// Device-authorization endpoint (RFC 8628 §3.1). Trailing slash is part of
59/// the path the official CLI sends.
60pub const DEVICE_AUTH_URL: &str = "https://auth.meta.com/oidc/device/authorization/";
61
62/// Device-code token endpoint (RFC 8628 §3.4).
63pub const TOKEN_URL: &str = "https://auth.meta.com/oidc/device/token/";
64
65/// Device-code grant type (RFC 8628).
66pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
67
68/// Inference/API host (`TBH_MINT_BASE_URL` default).
69pub const API_HOST: &str = "api.meta.ai";
70
71/// Origin the broker pins for every Muse request. Paths are allowlisted
72/// broker-side (`/v1/responses`, `/muse-code/models`).
73pub const API_ORIGIN: &str = "https://api.meta.ai";
74
75/// OpenAI Responses base (`{API_BASE_URL}/responses`).
76pub const API_BASE_URL: &str = "https://api.meta.ai/v1";
77
78/// Model-API-key mint endpoint (bearer = OAuth access token).
79pub const MINT_URL: &str = "https://api.meta.ai/muse-code/key";
80
81/// `x-api-version` the official CLI sends on the mint request.
82pub const MINT_API_VERSION: &str = "1.0.0";
83
84/// Where a Meta account finishes Model API onboarding / billing.
85pub const ONBOARDING_URL: &str = "https://dev.meta.ai";
86
87/// Environment override honoured by the official CLI: a Model API key here
88/// takes precedence over the account login.
89pub const API_KEY_ENV: &str = "META_API_KEY";
90
91/// Upstream error code returned by the mint when the account has not
92/// finished Model API onboarding.
93pub const ONBOARDING_REQUIRED_CODE: &str = "MODEL_API__ONBOARDING_REQUIRED";
94
95/// Honest User-Agent.
96pub const USER_AGENT: &str = concat!("SynapsCLI/", env!("CARGO_PKG_VERSION"));
97
98/// Minted keys carry no expiry. The official CLI uses a key until the API
99/// answers 401 and never re-mints proactively; this horizon only keeps the
100/// repo-wide expiry check from ever forcing a needless re-mint (each mint is
101/// a server-side side effect). Invalidation is 401-driven via the broker's
102/// rejected-token recovery.
103pub const API_KEY_NOMINAL_LIFETIME_MS: u64 = 365 * 24 * 60 * 60 * 1000;
104
105const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
106/// Hard ceiling so `slow_down` cannot grow unbounded.
107const MAX_POLL_INTERVAL_SECS: u64 = 30;
108/// Seconds added on each `slow_down` (RFC 8628 §3.5).
109const SLOW_DOWN_STEP_SECS: u64 = 5;
110/// Local wall-clock budget when the server omits `expires_in`.
111const DEFAULT_DEVICE_EXPIRES_IN_SECS: u64 = 600;
112
113/// Connect timeout for production device/poll/mint HTTP.
114pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
115/// Full request timeout for production device/poll/mint HTTP.
116pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
117/// Maximum response body bytes accepted from device/poll/mint endpoints.
118pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
119/// Logical sleep quantum for cancel checks (production wall-clock chunks).
120const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
121
122// ── Secret-safe errors ───────────────────────────────────────────────────────
123
124/// Failures from the Muse auth chain. Variants store **no** device codes,
125/// user codes, tokens, keys, or upstream bodies, so Display/Debug cannot leak
126/// them.
127#[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    /// The Meta account has not finished Model API onboarding.
139    OnboardingRequired,
140    /// The account must add a payment method / subscription first.
141    PaymentRequired,
142    /// The mint rejected the stored OAuth login (HTTP 401/403): re-login.
143    LoginRejected(u16),
144    HttpStatus(u16),
145    Transport,
146    ResponseTooLarge,
147    Persist,
148    /// The credential store is corrupt; carries our own repair text
149    /// (path + `mv` command, no secrets).
150    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    /// Login persistence failure: a corrupt store keeps its actionable text.
191    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    /// Typed re-mint failure: only a rejected or unfinished Meta login is a
199    /// credential failure; transport, 5xx / 429 and unusable mint bodies are
200    /// transient.
201    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            // Billing: re-login does not help, and the text says what does.
206            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// ── Injectable boundaries ────────────────────────────────────────────────────
236
237/// Minimal HTTP response used by the device-flow / mint state machine.
238#[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/// Injectable HTTP surface so tests never touch the network.
257#[async_trait]
258pub trait MuseHttp: Send + Sync {
259    /// Form POST to a pinned `auth.meta.com` device endpoint.
260    async fn post_form(
261        &self,
262        url: &str,
263        form: &[(&str, &str)],
264    ) -> Result<MuseHttpResponse, MuseAuthError>;
265
266    /// JSON POST with bearer auth to the pinned mint endpoint.
267    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/// Injectable clock (now + cancellable sleep) for deterministic poll tests.
277#[async_trait]
278pub trait MuseClock: Send + Sync {
279    fn now_millis(&self) -> u64;
280
281    /// Sleep `duration`, returning early with [`MuseAuthError::Cancelled`]
282    /// when `cancel` flips.
283    async fn sleep_cancellable(
284        &self,
285        duration: Duration,
286        cancel: &AtomicBool,
287    ) -> Result<(), MuseAuthError>;
288}
289
290/// Injectable browser opener (production uses `open_browser`).
291pub trait MuseBrowser: Send + Sync {
292    fn open(&self, url: &str) -> Result<(), MuseAuthError>;
293}
294
295// ── Device-flow types ────────────────────────────────────────────────────────
296
297#[derive(Clone)]
298pub struct DeviceAuthorization {
299    /// Opaque device code — never logged via public error types.
300    pub(crate) device_code: String,
301    pub user_code: String,
302    pub verification_uri: String,
303    /// `verification_uri_complete` when the server sent a valid one.
304    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    /// URI to open in the browser: the complete form (code pre-filled) when
326    /// available, otherwise the bare verification page.
327    pub fn browser_uri(&self) -> &str {
328        self.verification_uri_complete
329            .as_deref()
330            .unwrap_or(&self.verification_uri)
331    }
332}
333
334/// Outcome of one device-token poll (secret-safe; token only on Authorized).
335#[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/// A minted Model API key plus the non-secret account facts the mint echoes.
362#[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
379// ── Endpoint pinning ─────────────────────────────────────────────────────────
380
381/// Accept only the exact pinned HTTPS `auth.meta.com` device endpoints.
382pub 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
393/// Accept only the pinned mint host+path (no query, no alternate host).
394pub 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
409/// Verification destinations must be plain HTTPS pages on a Meta-owned host
410/// (`meta.com` / `*.meta.com`, `meta.ai` / `*.meta.ai`). Rejects userinfo,
411/// explicit ports, fragments, and look-alike hosts. Path/query are left to
412/// the server (the device page shape is not part of the pinned contract).
413pub 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
433// ── Mint request headers ─────────────────────────────────────────────────────
434
435/// Non-secret headers attached to the pinned mint request (bearer is set by
436/// the HTTP adapter and can never be overridden through this list).
437pub 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
446/// Exact mint body the official CLI sends for a returning login.
447pub const MINT_BODY: &str = r#"{"onboard":false}"#;
448
449// ── Parsing (pure) ───────────────────────────────────────────────────────────
450
451#[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    // A malformed/foreign complete URI is dropped (we fall back to the bare
520    // page + displayed code) rather than failing an otherwise valid login.
521    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
564/// Classify a mint response. Upstream bytes never enter the error: the
565/// onboarding code is matched as a fixed token, everything else maps to a
566/// static variant.
567pub 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
590/// OAuth access token → `refresh`; minted key → `access` (the bearer).
591pub 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
617/// Model API key from the environment override, if set and non-blank.
618pub 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
625/// Presence-only check for the `META_API_KEY` override (the value never
626/// leaves this module). Used by pickers: Muse is usable without a stored
627/// login when the override is set.
628pub fn env_api_key_present() -> bool {
629    api_key_from_env().is_some()
630}
631
632// ── Bounded body read ────────────────────────────────────────────────────────
633
634/// Read a response body with a hard byte cap. Fail closed if exceeded.
635pub 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
652// ── Device-flow state machine ────────────────────────────────────────────────
653
654/// Start device authorization (POST device/authorization/).
655pub 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
673/// One poll against the token endpoint.
674pub 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    // RFC 8628 errors arrive as HTTP 400 with an `error` body; only those
694    // recognised outcomes are meaningful on a non-2xx status.
695    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
706/// Poll until authorized, denied, expired, cancelled, or deadline. Returns
707/// the OAuth access token.
708pub 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
746// ── Key mint (pinned, no-redirect at production client) ──────────────────────
747
748/// Exchange the Meta OAuth access token for a Model API key. The OAuth token
749/// is sent only to the pinned mint URL. Callers must never log it.
750pub 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
769/// Mint and build the stored credential.
770pub 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
784// ── Full login (injectable) ──────────────────────────────────────────────────
785
786pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
787
788pub struct LoginHooks<'a, B> {
789    pub browser: &'a B,
790    /// When set, the user_code + verification URI are reported here (tests /
791    /// TUI) instead of being printed.
792    pub on_user_code: Option<UserCodeHook<'a>>,
793}
794
795/// Device start → user prompt → poll → mint → persist into `persist_key`
796/// (`muse` or `muse@<label>`) when given; `None` returns it unsaved.
797pub 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
838/// Production login entry (real network + system browser + auth.json).
839pub async fn login() -> Result<OAuthCredentials, String> {
840    login_into(Some(PROVIDER)).await
841}
842
843/// Production login; `Some(key)` persists ONLY into that slot, `None`
844/// returns the credential unsaved.
845pub 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
862/// Broker refresh: re-mint a Model API key from the stored OAuth access
863/// token (no browser). The OAuth token is carried over unchanged, so the
864/// compare-and-swap save in the token store matches.
865pub 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
876/// Test seam behind [`super::provider::RefreshEndpoints`]: re-mint against
877/// a loopback fake. The mint request is built exactly as in production
878/// (pinned `MINT_URL` validated, same headers and body); only the socket it
879/// is sent to differs, and only a plain-HTTP loopback URL is accepted.
880pub(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
939// ── Production adapters ──────────────────────────────────────────────────────
940
941struct ProductionHttp {
942    /// No-redirect client with connect + request timeouts for secret-bearing calls.
943    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        // Redirects are disabled; a 3xx means fail closed without following.
960        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        // Pin again immediately before attaching the OAuth token.
996        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            // Never let a caller-supplied Authorization override the bearer.
1003            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// ── Tests ────────────────────────────────────────────────────────────────────
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061
1062    /// Billing is fixed outside synaps; the next re-mint just works, so the
1063    /// text says "retry", not "log in again".
1064    #[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    /// Round-2 I2: a corrupt store at login is reported with its repair
1073    /// command, not as "credential persistence failed".
1074    #[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    /// The mint test seam only ever sends to plain-HTTP loopback.
1088    #[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        /// (url, bearer, form-or-headers, body)
1142        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        // interval 2s, then +5s after slow_down
1440        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        // Device start: client_id only (no scope, no PKCE).
1453        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        // Poll: RFC 8628 grant with the device code.
1459        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        // Mint: OAuth token as bearer, exact body + version header.
1467        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        // Deadline passes before the first poll.
1495        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        // Unchanged refresh slot → the token store's CAS save matches.
1543        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}