1use serde_json::{Map, Value};
6use tokio_util::sync::CancellationToken;
7use url::Url;
8use yapi_types::auth::OAuthCredential;
9
10use super::callback::{Reply, Server, error_page, query, success_page};
11use super::{
12 AuthError, AuthEvent, AuthPrompt, BoxFuture, Interaction, LoginOptions, OAuthProvider,
13 callback_host, error_text, expiry, form, json_body, pkce, post_form, send,
14};
15
16const DYNAMIC_CLIENT_ID: &str = "dynamic_agent_client";
18const AGENT_NAME_HINT: &str = "yapi";
19const RESOURCE: &str = "https://api.openai.com/v1";
20const CALLBACK_PATH: &str = "/auth/callback";
21const DIRECT_TOKEN_SCOPE: &str = "chatgpt.tokens.use.direct";
22const SCOPE: &str = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
23const EXPIRY_MARGIN_MS: f64 = 3.0 * 60.0 * 1000.0;
25
26#[derive(Clone, Debug)]
28pub struct ChatGptOAuth {
29 pub authorize_url: String,
31 pub token_url: String,
33 pub callback_port: u16,
35 pub redirect_uri: String,
37}
38
39impl Default for ChatGptOAuth {
40 fn default() -> ChatGptOAuth {
41 ChatGptOAuth {
42 authorize_url: "https://auth.openai.com/api/accounts/authorize".into(),
43 token_url: "https://auth.openai.com/api/accounts/oauth/token".into(),
44 callback_port: 1455,
45 redirect_uri: "http://127.0.0.1:1455/auth/callback".into(),
46 }
47 }
48}
49
50#[derive(Debug)]
52struct Authorization {
53 code: String,
54 client_id: String,
55}
56
57fn from_callback(url: &Url, expected_state: &str) -> Result<Authorization, AuthError> {
58 let code = query(url, "code").filter(|code| !code.is_empty());
59 let code = code.ok_or_else(|| AuthError::failed("Missing authorization code"))?;
60 let state = query(url, "state").filter(|state| !state.is_empty());
61 let state = state.ok_or_else(|| AuthError::failed("Missing OAuth state"))?;
62 if state != expected_state {
63 return Err(AuthError::failed("OAuth state mismatch"));
64 }
65 let client_id = query(url, "client_id")
66 .map(|id| id.trim().to_owned())
67 .filter(|id| !id.is_empty())
68 .ok_or_else(|| {
69 AuthError::failed(
70 "OpenAI OAuth registration callback did not contain an issued client ID",
71 )
72 })?;
73 Ok(Authorization { code, client_id })
74}
75
76fn is_uuid(value: &str) -> bool {
77 let groups: Vec<&str> = value.split('-').collect();
78 groups.len() == 5
79 && groups.iter().zip([8, 4, 4, 4, 12]).all(|(group, length)| {
80 group.len() == length && group.chars().all(|c| c.is_ascii_hexdigit())
81 })
82}
83
84fn token_string(token: &Value, field: &str) -> Result<String, AuthError> {
85 token
86 .get(field)
87 .and_then(Value::as_str)
88 .filter(|value| !value.trim().is_empty())
89 .map(str::to_owned)
90 .ok_or_else(|| {
91 AuthError::Failed(format!("OpenAI OAuth token response has invalid {field}"))
92 })
93}
94
95fn credential(token: &Value, client_id: &str) -> Result<OAuthCredential, AuthError> {
96 let access = token_string(token, "access_token")?;
97 let refresh = token_string(token, "refresh_token")?;
98 let scope = token_string(token, "scope")?;
99 let expires_in = token
100 .get("expires_in")
101 .and_then(Value::as_f64)
102 .filter(|value| value.is_finite() && *value > 0.0)
103 .ok_or_else(|| AuthError::failed("OpenAI OAuth token response has invalid expires_in"))?;
104 let scopes: Vec<Value> = scope
105 .split_whitespace()
106 .map(|scope| Value::String(scope.to_owned()))
107 .collect();
108 if !scopes.iter().any(|scope| scope == DIRECT_TOKEN_SCOPE) {
109 return Err(AuthError::Failed(format!(
110 "OpenAI OAuth grant did not include {DIRECT_TOKEN_SCOPE}"
111 )));
112 }
113 let mut extra = Map::new();
114 extra.insert("clientId".into(), Value::String(client_id.to_owned()));
115 extra.insert("scopes".into(), Value::Array(scopes));
116 Ok(OAuthCredential {
117 access,
118 refresh,
119 expires: expiry(expires_in, EXPIRY_MARGIN_MS) as f64,
120 extra,
121 })
122}
123
124impl ChatGptOAuth {
125 async fn request_token(
126 &self,
127 fields: &[(&str, &str)],
128 cancel: &CancellationToken,
129 ) -> Result<Value, AuthError> {
130 let request = post_form(&self.token_url, fields);
131 let response = send(request, cancel).await?;
132 if !response.status().is_success() {
133 let status = response.status().as_u16();
134 let text = error_text(response).await;
135 return Err(AuthError::Failed(format!(
136 "OpenAI OAuth token request failed ({status}): {text}"
137 )));
138 }
139 match json_body(response).await {
140 object @ Value::Object(_) => Ok(object),
141 _ => Err(AuthError::failed(
142 "OpenAI OAuth token response must be an object",
143 )),
144 }
145 }
146
147 fn parse_manual_input(&self, input: &str, state: &str) -> Result<Authorization, AuthError> {
148 let url = Url::parse(input.trim())
149 .map_err(|_| AuthError::failed("Paste the full callback URL from the browser"))?;
150 let expected =
151 Url::parse(&self.redirect_uri).map_err(|err| AuthError::Failed(err.to_string()))?;
152 if url.origin() != expected.origin() || url.path() != expected.path() {
153 return Err(AuthError::Failed(format!(
154 "The pasted callback URL must start with {}",
155 self.redirect_uri
156 )));
157 }
158 if let Some(error) = query(&url, "error") {
159 return Err(AuthError::Failed(format!(
160 "ChatGPT authorization failed: {error}"
161 )));
162 }
163 from_callback(&url, state)
164 }
165
166 async fn start_server(
167 &self,
168 state: String,
169 cancel: &CancellationToken,
170 ) -> std::io::Result<Server<Authorization>> {
171 let handler = move |_method: &str, url: &Url, _handled: bool| -> Reply<Authorization> {
172 if url.path() != CALLBACK_PATH {
173 return Reply::page(404, error_page("Callback route not found.", None));
174 }
175 if let Some(error) = query(url, "error") {
176 return Reply {
177 status: 400,
178 html: error_page(
179 "ChatGPT was not connected.",
180 Some(&format!("Error: {error}")),
181 ),
182 outcome: Some(Err(AuthError::Failed(format!(
183 "ChatGPT authorization failed: {error}"
184 )))),
185 };
186 }
187 match from_callback(url, &state) {
188 Ok(authorization) => Reply {
189 status: 200,
190 html: success_page(
191 "ChatGPT authentication completed. You can close this window.",
192 ),
193 outcome: Some(Ok(authorization)),
194 },
195 Err(err) => Reply::page(400, error_page(&err.to_string(), None)),
196 }
197 };
198 Server::start(
199 &callback_host(),
200 self.callback_port,
201 CALLBACK_PATH,
202 cancel,
203 Box::new(handler),
204 )
205 .await
206 }
207
208 async fn login_chatgpt(
209 &self,
210 interaction: &Interaction,
211 options: &LoginOptions,
212 ) -> Result<OAuthCredential, AuthError> {
213 let device_id = options
214 .device_id
215 .as_deref()
216 .filter(|id| is_uuid(id))
217 .ok_or_else(|| {
218 AuthError::failed(
219 "Sign in with ChatGPT requires a device ID (UUID) for this installation",
220 )
221 })?;
222 let host_id = format!("urn:uuid:{}", device_id.to_ascii_lowercase());
223 let pkce = pkce::generate()?;
224 let state = pkce::random_base64url()?;
225 let nonce = pkce::random_base64url()?;
226 let mut server = match self.start_server(state.clone(), interaction.cancel()).await {
227 Ok(server) => Some(server),
228 Err(err) => {
229 interaction.notify(AuthEvent::Info {
230 message: format!(
231 "Could not listen on {}; paste the final redirect URL to continue. {err}",
232 self.redirect_uri
233 ),
234 links: Vec::new(),
235 });
236 None
237 }
238 };
239 let query = form(&[
240 ("client_id", DYNAMIC_CLIENT_ID),
241 ("agent_name_hint", AGENT_NAME_HINT),
242 ("ext_agent_host_id", &host_id),
243 ("response_type", "code"),
244 ("redirect_uri", &self.redirect_uri),
245 ("resource", RESOURCE),
246 ("scope", SCOPE),
247 ("state", &state),
248 ("code_challenge", &pkce.challenge),
249 ("code_challenge_method", "S256"),
250 ("nonce", &nonce),
251 ]);
252 interaction.notify(AuthEvent::AuthUrl {
253 url: format!("{}?{query}", self.authorize_url),
254 instructions: Some("Complete sign-in in your browser. If the callback does not complete, paste the final redirect URL here.".into()),
255 });
256 let withdraw = interaction.cancel().child_token();
257 let _withdraw_on_return = withdraw.clone().drop_guard();
258 let manual = async {
259 let input = interaction
260 .prompt_until(
261 AuthPrompt::ManualCode {
262 message:
263 "Complete login in your browser, or paste the final redirect URL here:"
264 .into(),
265 placeholder: Some(self.redirect_uri.clone()),
266 },
267 withdraw,
268 )
269 .await?;
270 self.parse_manual_input(&input, &state)
271 };
272 let authorization = match server.as_mut() {
273 Some(server) => tokio::select! {
274 result = server.wait() => result,
275 result = manual => result,
276 },
277 None => manual.await,
278 }?;
279 drop(server);
280 interaction.notify(AuthEvent::Progress {
281 message: "Exchanging authorization code for tokens...".into(),
282 });
283 let token = self
284 .request_token(
285 &[
286 ("grant_type", "authorization_code"),
287 ("client_id", &authorization.client_id),
288 ("code", &authorization.code),
289 ("code_verifier", &pkce.verifier),
290 ("redirect_uri", &self.redirect_uri),
291 ("resource", RESOURCE),
292 ],
293 interaction.cancel(),
294 )
295 .await?;
296 if token
298 .get("id_token")
299 .and_then(Value::as_str)
300 .is_none_or(|id| id.trim().is_empty())
301 {
302 return Err(AuthError::failed(
303 "OpenAI OAuth token response did not contain an ID token",
304 ));
305 }
306 credential(&token, &authorization.client_id)
307 }
308}
309
310impl OAuthProvider for ChatGptOAuth {
311 fn name(&self) -> &str {
312 "OpenAI (ChatGPT subscription)"
313 }
314
315 fn login_label(&self) -> Option<&str> {
316 Some("Sign in with ChatGPT")
317 }
318
319 fn login<'a>(
320 &'a self,
321 interaction: &'a Interaction,
322 options: &'a LoginOptions,
323 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
324 Box::pin(async move {
325 self.login_chatgpt(interaction, options)
326 .await
327 .map_err(|err| {
328 if interaction.cancel().is_cancelled() {
329 AuthError::Cancelled
330 } else {
331 err
332 }
333 })
334 })
335 }
336
337 fn refresh<'a>(
338 &'a self,
339 credential: &'a OAuthCredential,
340 cancel: &'a CancellationToken,
341 ) -> BoxFuture<'a, Result<OAuthCredential, AuthError>> {
342 Box::pin(async move {
343 let client_id = credential
344 .extra
345 .get("clientId")
346 .and_then(Value::as_str)
347 .filter(|id| !id.trim().is_empty())
348 .ok_or_else(|| {
349 AuthError::failed(
350 "Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT",
351 )
352 })?;
353 let token = self
354 .request_token(
355 &[
356 ("grant_type", "refresh_token"),
357 ("client_id", client_id),
358 ("refresh_token", &credential.refresh),
359 ("resource", RESOURCE),
360 ],
361 cancel,
362 )
363 .await?;
364 super::chatgpt::credential(&token, client_id)
365 })
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn validates_ids_and_callbacks() {
375 assert!(is_uuid("123e4567-E89b-12d3-a456-426614174000"));
376 assert!(!is_uuid("123e4567e89b12d3a456426614174000"));
377 let oauth = ChatGptOAuth::default();
378 let error = |input: &str| {
379 oauth
380 .parse_manual_input(input, "s")
381 .unwrap_err()
382 .to_string()
383 };
384 assert_eq!(
385 error("nope"),
386 "Paste the full callback URL from the browser"
387 );
388 assert_eq!(
389 error("http://localhost:1455/auth/callback?code=c"),
390 "The pasted callback URL must start with http://127.0.0.1:1455/auth/callback"
391 );
392 assert_eq!(
393 error("http://127.0.0.1:1455/auth/callback?code=c&state=x&client_id=i"),
394 "OAuth state mismatch"
395 );
396 let ok = oauth
397 .parse_manual_input(
398 "http://127.0.0.1:1455/auth/callback?code=c&state=s&client_id=i",
399 "s",
400 )
401 .unwrap();
402 assert_eq!((ok.code.as_str(), ok.client_id.as_str()), ("c", "i"));
403 }
404}