openai_auth/
client.rs

1use url::Url;
2
3use crate::types::TokenResponse;
4use crate::{OAuthConfig, OAuthFlow, OpenAIAuthError, Result, TokenSet};
5
6/// Async OpenAI OAuth client for authentication
7///
8/// This client handles the OAuth 2.0 flow with PKCE for OpenAI/ChatGPT authentication
9/// using asynchronous operations (runtime-agnostic).
10///
11/// For blocking/synchronous operations, use `blocking::OAuthClient` (requires the `blocking` feature).
12///
13/// # Example
14///
15/// ```no_run
16/// use openai_auth::{OAuthClient, OAuthConfig};
17///
18/// #[tokio::main]
19/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
20///     let client = OAuthClient::new(OAuthConfig::default())?;
21///     let flow = client.start_flow()?;
22///     
23///     println!("Visit: {}", flow.authorization_url);
24///     // User authorizes and you get the code...
25///     
26///     let tokens = client.exchange_code("code", &flow.pkce_verifier).await?;
27///     println!("Got tokens!");
28///     Ok(())
29/// }
30/// ```
31#[derive(Clone)]
32pub struct OAuthClient {
33    config: OAuthConfig,
34}
35
36impl OAuthClient {
37    /// Create a new OAuth client with the given configuration
38    ///
39    /// # Arguments
40    ///
41    /// * `config` - OAuth configuration (client ID, endpoints, redirect URI)
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the configuration is invalid
46    pub fn new(config: OAuthConfig) -> Result<Self> {
47        Ok(Self { config })
48    }
49
50    /// Start the OAuth authorization flow
51    ///
52    /// This generates a PKCE challenge and creates the authorization URL
53    /// that the user should visit to authorize the application.
54    ///
55    /// # Returns
56    ///
57    /// An `OAuthFlow` containing the authorization URL, PKCE verifier,
58    /// and CSRF state token
59    ///
60    /// # Example
61    ///
62    /// ```no_run
63    /// # use openai_auth::{OAuthClient, OAuthConfig};
64    /// # #[tokio::main]
65    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
66    /// let client = OAuthClient::new(OAuthConfig::default())?;
67    /// let flow = client.start_flow()?;
68    /// println!("Visit: {}", flow.authorization_url);
69    /// # Ok(())
70    /// # }
71    /// ```
72    pub fn start_flow(&self) -> Result<OAuthFlow> {
73        // Generate random state for CSRF protection
74        let state = crate::types::generate_random_state();
75        let (pkce_challenge, pkce_verifier) = crate::types::generate_pkce_pair();
76
77        // Build authorization URL
78        let mut url = Url::parse(&self.config.auth_url)?;
79        url.query_pairs_mut()
80            .append_pair("response_type", "code")
81            .append_pair("client_id", &self.config.client_id)
82            .append_pair("redirect_uri", &self.config.redirect_uri)
83            .append_pair("scope", "openid profile email offline_access")
84            .append_pair("code_challenge", &pkce_challenge)
85            .append_pair("code_challenge_method", "S256")
86            .append_pair("state", &state)
87            .append_pair("id_token_add_organizations", "true")
88            .append_pair("codex_cli_simplified_flow", "true")
89            .append_pair("originator", "codex_cli_rs");
90
91        Ok(OAuthFlow {
92            authorization_url: url.to_string(),
93            pkce_verifier,
94            state,
95        })
96    }
97
98    /// Extract ChatGPT account ID from an access token
99    ///
100    /// OpenAI access tokens contain the ChatGPT account ID in their JWT claims.
101    /// This is useful for making API requests that require the account ID.
102    ///
103    /// # Arguments
104    ///
105    /// * `access_token` - The access token to extract the account ID from
106    ///
107    /// # Returns
108    ///
109    /// The ChatGPT account ID as a string
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the JWT is malformed or doesn't contain the account ID
114    pub fn extract_account_id(&self, access_token: &str) -> Result<String> {
115        crate::jwt::extract_account_id(access_token)
116    }
117
118    /// Exchange an authorization code for access and refresh tokens
119    ///
120    /// After the user authorizes the application, they'll receive an authorization
121    /// code. This method exchanges that code for access and refresh tokens.
122    ///
123    /// # Arguments
124    ///
125    /// * `code` - The authorization code from the OAuth callback
126    /// * `verifier` - The PKCE verifier from the original flow
127    ///
128    /// # Returns
129    ///
130    /// A `TokenSet` containing access token, refresh token, and expiration time
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the token exchange fails (invalid code, network error, etc.)
135    ///
136    /// # Example
137    ///
138    /// ```no_run
139    /// # use openai_auth::{OAuthClient, OAuthConfig};
140    /// # #[tokio::main]
141    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
142    /// # let client = OAuthClient::new(OAuthConfig::default())?;
143    /// # let flow = client.start_flow()?;
144    /// let code = "authorization_code_from_callback";
145    /// let tokens = client.exchange_code(code, &flow.pkce_verifier).await?;
146    /// println!("Access token expires in: {:?}", tokens.expires_in());
147    /// # Ok(())
148    /// # }
149    /// ```
150    pub async fn exchange_code(&self, code: &str, verifier: &str) -> Result<TokenSet> {
151        let client = reqwest::Client::new();
152
153        let params = [
154            ("grant_type", "authorization_code"),
155            ("client_id", &self.config.client_id),
156            ("code", code),
157            ("code_verifier", verifier),
158            ("redirect_uri", &self.config.redirect_uri),
159        ];
160
161        let response = client
162            .post(&self.config.token_url)
163            .header("Content-Type", "application/x-www-form-urlencoded")
164            .form(&params)
165            .send()
166            .await?;
167
168        if !response.status().is_success() {
169            let status = response.status().as_u16();
170            let body = response.text().await.unwrap_or_default();
171            return Err(OpenAIAuthError::Http { status, body });
172        }
173
174        let token_response: TokenResponse = response.json().await?;
175        Ok(TokenSet::from(token_response))
176    }
177
178    /// Exchange an authorization code and return a TokenSet with an API key.
179    ///
180    /// This mirrors the Codex CLI flow by exchanging the `id_token` for an
181    /// OpenAI API key using the token-exchange grant.
182    pub async fn exchange_code_for_api_key(&self, code: &str, verifier: &str) -> Result<TokenSet> {
183        let mut tokens = self.exchange_code(code, verifier).await?;
184        let id_token = tokens.id_token.as_deref().ok_or_else(|| {
185            OpenAIAuthError::TokenExchange("missing id_token for api key exchange".to_string())
186        })?;
187        let api_key = self.obtain_api_key(id_token).await?;
188        tokens.api_key = Some(api_key);
189        Ok(tokens)
190    }
191
192    /// Exchange an OpenAI id_token for an API key access token.
193    pub async fn obtain_api_key(&self, id_token: &str) -> Result<String> {
194        #[derive(serde::Deserialize)]
195        struct ExchangeResponse {
196            access_token: String,
197        }
198
199        let client = reqwest::Client::new();
200        let params = [
201            (
202                "grant_type",
203                "urn:ietf:params:oauth:grant-type:token-exchange",
204            ),
205            ("client_id", &self.config.client_id),
206            ("requested_token", "openai-api-key"),
207            ("subject_token", id_token),
208            (
209                "subject_token_type",
210                "urn:ietf:params:oauth:token-type:id_token",
211            ),
212        ];
213
214        let response = client
215            .post(&self.config.token_url)
216            .header("Content-Type", "application/x-www-form-urlencoded")
217            .form(&params)
218            .send()
219            .await?;
220
221        if !response.status().is_success() {
222            let status = response.status().as_u16();
223            let body = response.text().await.unwrap_or_default();
224            return Err(OpenAIAuthError::Http { status, body });
225        }
226
227        let exchange: ExchangeResponse = response.json().await?;
228        Ok(exchange.access_token)
229    }
230
231    /// Refresh an expired access token
232    ///
233    /// When an access token expires, use the refresh token to obtain a new
234    /// access token without requiring the user to re-authorize.
235    ///
236    /// # Arguments
237    ///
238    /// * `refresh_token` - The refresh token from a previous token exchange
239    ///
240    /// # Returns
241    ///
242    /// A new `TokenSet` with fresh access token
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the refresh fails (invalid refresh token, network error, etc.)
247    ///
248    /// # Example
249    ///
250    /// ```no_run
251    /// # use openai_auth::{OAuthClient, OAuthConfig, TokenSet};
252    /// # #[tokio::main]
253    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
254    /// # let client = OAuthClient::new(OAuthConfig::default())?;
255    /// # let tokens = TokenSet {
256    /// #     access_token: "".into(),
257    /// #     refresh_token: "refresh".into(),
258    /// #     expires_at: 0,
259    /// # };
260    /// if tokens.is_expired() {
261    ///     let new_tokens = client.refresh_token(&tokens.refresh_token).await?;
262    ///     println!("Refreshed! New token expires in: {:?}", new_tokens.expires_in());
263    /// }
264    /// # Ok(())
265    /// # }
266    /// ```
267    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenSet> {
268        let client = reqwest::Client::new();
269
270        let params = [
271            ("grant_type", "refresh_token"),
272            ("refresh_token", refresh_token),
273            ("client_id", &self.config.client_id),
274        ];
275
276        let response = client
277            .post(&self.config.token_url)
278            .header("Content-Type", "application/x-www-form-urlencoded")
279            .form(&params)
280            .send()
281            .await?;
282
283        if !response.status().is_success() {
284            let status = response.status().as_u16();
285            let body = response.text().await.unwrap_or_default();
286            return Err(OpenAIAuthError::ApiKeyExchange { status, body });
287        }
288
289        let token_response: TokenResponse = response.json().await?;
290        Ok(TokenSet::from(token_response))
291    }
292}
293
294impl Default for OAuthClient {
295    fn default() -> Self {
296        Self::new(OAuthConfig::default()).expect("Failed to create OAuth client with defaults")
297    }
298}