Skip to main content

yapi_ai/auth/
meta.rs

1//! Meta (Muse subscription): an RFC 8628 device authorization against
2//! auth.meta.com whose identity token mints a Model API key that lives about
3//! a day. The identity token is stored as the refresh token and the key as
4//! the access token, so a refresh mints a new key. Port of `oauth/meta.ts`.
5
6use std::time::Duration;
7
8use serde_json::{Map, Value};
9use tokio_util::sync::CancellationToken;
10use yapi_types::auth::OAuthCredential;
11
12use super::device::{DEVICE_CODE_GRANT, Poll, poll_device_code, positive, trusted_url};
13use super::{
14    AuthError, AuthEvent, BoxFuture, Interaction, LoginOptions, OAuthProvider, json_body, now_ms,
15    post_form, send,
16};
17
18const CLIENT_ID: &str = "1031625952748946";
19const API_KEY_LIFETIME_MS: u64 = 24 * 60 * 60 * 1000;
20const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// The Meta sign-in and its endpoints.
23#[derive(Clone, Debug)]
24pub struct MetaOAuth {
25    /// Device authorization endpoint.
26    pub device_authorization_url: String,
27    /// Device token endpoint.
28    pub device_token_url: String,
29    /// Model API key mint endpoint.
30    pub api_key_mint_url: String,
31}
32
33impl Default for MetaOAuth {
34    fn default() -> MetaOAuth {
35        MetaOAuth {
36            device_authorization_url: "https://auth.meta.com/oidc/device/authorization/".into(),
37            device_token_url: "https://auth.meta.com/oidc/device/token/".into(),
38            api_key_mint_url: "https://api.meta.ai/muse-code/key".into(),
39        }
40    }
41}
42
43fn error_detail(json: &Value) -> String {
44    ["error_description", "detail", "message", "error"]
45        .iter()
46        .find_map(|key| {
47            json[*key]
48                .as_str()
49                .map(str::trim)
50                .filter(|value| !value.is_empty())
51        })
52        .map(|value| format!(": {value}"))
53        .unwrap_or_default()
54}
55
56impl MetaOAuth {
57    /// A Model API key for an identity token.
58    async fn mint(
59        &self,
60        identity: &str,
61        cancel: &CancellationToken,
62    ) -> Result<OAuthCredential, AuthError> {
63        let request = crate::http::client()
64            .post(&self.api_key_mint_url)
65            .header("Accept", "application/json")
66            .header("Authorization", format!("Bearer {identity}"))
67            .header("Content-Type", "application/json")
68            .header("x-api-version", "1.0.0")
69            .timeout(REQUEST_TIMEOUT)
70            .body("{}");
71        let response = send(request, cancel).await?;
72        let status = response.status().as_u16();
73        let json = json_body(response).await;
74        if status == 401 || status == 403 {
75            return Err(AuthError::Failed(format!(
76                "Meta session expired (status {status}). Run `/login meta` to sign in again.{}",
77                error_detail(&json)
78            )));
79        }
80        if !(200..300).contains(&status) {
81            return Err(AuthError::Failed(format!(
82                "Meta API key mint failed with status {status}{}",
83                error_detail(&json)
84            )));
85        }
86        let Some(key) = json["api_key"].as_str().filter(|key| !key.is_empty()) else {
87            let action = trusted_url(&json["action_url"])
88                .map(|url| format!(" Complete setup at {url}"))
89                .unwrap_or_default();
90            return Err(AuthError::Failed(format!(
91                "Meta did not issue an API key.{action}"
92            )));
93        };
94        Ok(OAuthCredential {
95            access: key.to_owned(),
96            refresh: identity.to_owned(),
97            expires: (now_ms() + API_KEY_LIFETIME_MS) as f64,
98            extra: Map::new(),
99        })
100    }
101
102    async fn login_meta(&self, interaction: &Interaction) -> Result<OAuthCredential, AuthError> {
103        let cancel = interaction.cancel();
104        let response = send(
105            post_form(&self.device_authorization_url, &[("client_id", CLIENT_ID)])
106                .timeout(REQUEST_TIMEOUT),
107            cancel,
108        )
109        .await?;
110        let status = response.status().as_u16();
111        let json = json_body(response).await;
112        if !(200..300).contains(&status) {
113            return Err(AuthError::Failed(format!(
114                "Meta device authorization failed with status {status}{}",
115                error_detail(&json)
116            )));
117        }
118        let verification = trusted_url(&json["verification_uri_complete"])
119            .or_else(|| trusted_url(&json["verification_uri"]));
120        let (Some(device_code), Some(user_code), Some(verification)) = (
121            json["device_code"].as_str().filter(|code| !code.is_empty()),
122            json["user_code"].as_str().filter(|code| !code.is_empty()),
123            verification,
124        ) else {
125            return Err(AuthError::Failed(format!(
126                "Invalid Meta device authorization response: {}",
127                yapi_types::json::stringify(&json)
128            )));
129        };
130        let interval = positive(&json["interval"]);
131        let expires_in = positive(&json["expires_in"]);
132        interaction.notify(AuthEvent::DeviceCode {
133            user_code: user_code.to_owned(),
134            verification_uri: verification,
135            interval_seconds: interval,
136            expires_in_seconds: expires_in,
137        });
138        let identity = poll_device_code(interval, expires_in, true, cancel, || {
139            let request = post_form(
140                &self.device_token_url,
141                &[
142                    ("grant_type", DEVICE_CODE_GRANT),
143                    ("device_code", device_code),
144                    ("client_id", CLIENT_ID),
145                ],
146            )
147            .timeout(REQUEST_TIMEOUT);
148            async move {
149                let response = send(request, cancel).await?;
150                let status = response.status().as_u16();
151                let json = json_body(response).await;
152                if (200..300).contains(&status)
153                    && let Some(token) = json["access_token"].as_str().filter(|t| !t.is_empty())
154                {
155                    return Ok(Poll::Complete(token.to_owned()));
156                }
157                Ok(match json["error"].as_str() {
158                    Some("authorization_pending") => Poll::Pending,
159                    Some("slow_down") => Poll::SlowDown(positive(&json["interval"])),
160                    Some("access_denied") => Poll::Failed("Meta login was denied.".into()),
161                    Some("expired_token") => Poll::Failed(
162                        "Meta device authorization expired. Please restart login.".into(),
163                    ),
164                    _ => Poll::Failed(format!(
165                        "Meta device token request failed with status {status}{}",
166                        error_detail(&json)
167                    )),
168                })
169            }
170        })
171        .await?;
172        interaction.notify(AuthEvent::Progress {
173            message: "Enabling Meta Model API access...".into(),
174        });
175        self.mint(&identity, cancel).await
176    }
177}
178
179impl OAuthProvider for MetaOAuth {
180    fn name(&self) -> &str {
181        "Meta (Muse subscription)"
182    }
183
184    fn login_label(&self) -> Option<&str> {
185        Some("Sign in with Meta")
186    }
187
188    fn login<'a>(
189        &'a self,
190        interaction: &'a Interaction,
191        _options: &'a LoginOptions,
192    ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
193        Box::pin(self.login_meta(interaction))
194    }
195
196    fn refresh<'a>(
197        &'a self,
198        credential: &'a OAuthCredential,
199        cancel: &'a CancellationToken,
200    ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
201        Box::pin(self.mint(&credential.refresh, cancel))
202    }
203}