Skip to main content

yapi_ai/auth/
chatgpt.rs

1//! Sign in with ChatGPT for the `openai` provider: a public client registered
2//! per sign-in, whose user token goes straight to api.openai.com. Port of
3//! `oauth/openai-chatgpt.ts`.
4
5use 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
16/// Every sign-in registers a new client under this id. The callback includes the issued one.
17const 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";
23/// Refresh this long before the real expiry.
24const EXPIRY_MARGIN_MS: f64 = 3.0 * 60.0 * 1000.0;
25
26/// The ChatGPT sign-in and its endpoints.
27#[derive(Clone, Debug)]
28pub struct ChatGptOAuth {
29    /// Authorization page.
30    pub authorize_url: String,
31    /// Token endpoint.
32    pub token_url: String,
33    /// Loopback callback port.
34    pub callback_port: u16,
35    /// Redirect URI, which must reach the callback port.
36    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/// The code and the client id the authorization server issued.
51#[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        // The ID token is part of the response contract. yapi does not read it.
297        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}