1use 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#[derive(Clone, Debug)]
24pub struct MetaOAuth {
25 pub device_authorization_url: String,
27 pub device_token_url: String,
29 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 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}