openai_auth/
client.rs

1use oauth2::PkceCodeChallenge;
2use url::Url;
3
4use crate::types::TokenResponse;
5use crate::{OAuthConfig, OAuthFlow, OpenAIAuthError, Result, TokenSet};
6
7/// Async OpenAI OAuth client for authentication
8///
9/// This client handles the OAuth 2.0 flow with PKCE for OpenAI/ChatGPT authentication
10/// using asynchronous operations (runtime-agnostic).
11///
12/// For blocking/synchronous operations, use `blocking::OAuthClient` (requires the `blocking` feature).
13///
14/// # Example
15///
16/// ```no_run
17/// use openai_auth::{OAuthClient, OAuthConfig};
18///
19/// #[tokio::main]
20/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
21///     let client = OAuthClient::new(OAuthConfig::default())?;
22///     let flow = client.start_flow()?;
23///     
24///     println!("Visit: {}", flow.authorization_url);
25///     // User authorizes and you get the code...
26///     
27///     let tokens = client.exchange_code("code", &flow.pkce_verifier).await?;
28///     println!("Got tokens!");
29///     Ok(())
30/// }
31/// ```
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 PKCE challenge and verifier
74        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
75
76        // Generate random state for CSRF protection
77        let state = crate::types::generate_random_state();
78
79        // Build authorization URL
80        let mut url = Url::parse(&self.config.auth_url)?;
81        url.query_pairs_mut()
82            .append_pair("response_type", "code")
83            .append_pair("client_id", &self.config.client_id)
84            .append_pair("redirect_uri", &self.config.redirect_uri)
85            .append_pair("scope", "openid profile email offline_access")
86            .append_pair("code_challenge", pkce_challenge.as_str())
87            .append_pair("code_challenge_method", "S256")
88            .append_pair("state", &state)
89            .append_pair("id_token_add_organizations", "true")
90            .append_pair("codex_cli_simplified_flow", "true")
91            .append_pair("originator", "codex_cli_rs");
92
93        Ok(OAuthFlow {
94            authorization_url: url.to_string(),
95            pkce_verifier: pkce_verifier.secret().to_string(),
96            state,
97        })
98    }
99
100    /// Extract ChatGPT account ID from an access token
101    ///
102    /// OpenAI access tokens contain the ChatGPT account ID in their JWT claims.
103    /// This is useful for making API requests that require the account ID.
104    ///
105    /// # Arguments
106    ///
107    /// * `access_token` - The access token to extract the account ID from
108    ///
109    /// # Returns
110    ///
111    /// The ChatGPT account ID as a string
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the JWT is malformed or doesn't contain the account ID
116    pub fn extract_account_id(&self, access_token: &str) -> Result<String> {
117        crate::jwt::extract_account_id(access_token)
118    }
119
120    /// Exchange an authorization code for access and refresh tokens
121    ///
122    /// After the user authorizes the application, they'll receive an authorization
123    /// code. This method exchanges that code for access and refresh tokens.
124    ///
125    /// # Arguments
126    ///
127    /// * `code` - The authorization code from the OAuth callback
128    /// * `verifier` - The PKCE verifier from the original flow
129    ///
130    /// # Returns
131    ///
132    /// A `TokenSet` containing access token, refresh token, and expiration time
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the token exchange fails (invalid code, network error, etc.)
137    ///
138    /// # Example
139    ///
140    /// ```no_run
141    /// # use openai_auth::{OAuthClient, OAuthConfig};
142    /// # #[tokio::main]
143    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
144    /// # let client = OAuthClient::new(OAuthConfig::default())?;
145    /// # let flow = client.start_flow()?;
146    /// let code = "authorization_code_from_callback";
147    /// let tokens = client.exchange_code(code, &flow.pkce_verifier).await?;
148    /// println!("Access token expires in: {:?}", tokens.expires_in());
149    /// # Ok(())
150    /// # }
151    /// ```
152    pub async fn exchange_code(&self, code: &str, verifier: &str) -> Result<TokenSet> {
153        let client = reqwest::Client::new();
154
155        let params = [
156            ("grant_type", "authorization_code"),
157            ("client_id", &self.config.client_id),
158            ("code", code),
159            ("code_verifier", verifier),
160            ("redirect_uri", &self.config.redirect_uri),
161        ];
162
163        let response = client
164            .post(&self.config.token_url)
165            .header("Content-Type", "application/x-www-form-urlencoded")
166            .form(&params)
167            .send()
168            .await?;
169
170        if !response.status().is_success() {
171            let status = response.status().as_u16();
172            let body = response.text().await.unwrap_or_default();
173            return Err(OpenAIAuthError::Http { status, body });
174        }
175
176        let token_response: TokenResponse = response.json().await?;
177        Ok(TokenSet::from(token_response))
178    }
179
180    /// Refresh an expired access token
181    ///
182    /// When an access token expires, use the refresh token to obtain a new
183    /// access token without requiring the user to re-authorize.
184    ///
185    /// # Arguments
186    ///
187    /// * `refresh_token` - The refresh token from a previous token exchange
188    ///
189    /// # Returns
190    ///
191    /// A new `TokenSet` with fresh access token
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the refresh fails (invalid refresh token, network error, etc.)
196    ///
197    /// # Example
198    ///
199    /// ```no_run
200    /// # use openai_auth::{OAuthClient, OAuthConfig, TokenSet};
201    /// # #[tokio::main]
202    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
203    /// # let client = OAuthClient::new(OAuthConfig::default())?;
204    /// # let tokens = TokenSet {
205    /// #     access_token: "".into(),
206    /// #     refresh_token: "refresh".into(),
207    /// #     expires_at: 0,
208    /// # };
209    /// if tokens.is_expired() {
210    ///     let new_tokens = client.refresh_token(&tokens.refresh_token).await?;
211    ///     println!("Refreshed! New token expires in: {:?}", new_tokens.expires_in());
212    /// }
213    /// # Ok(())
214    /// # }
215    /// ```
216    pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenSet> {
217        let client = reqwest::Client::new();
218
219        let params = [
220            ("grant_type", "refresh_token"),
221            ("refresh_token", refresh_token),
222            ("client_id", &self.config.client_id),
223        ];
224
225        let response = client
226            .post(&self.config.token_url)
227            .header("Content-Type", "application/x-www-form-urlencoded")
228            .form(&params)
229            .send()
230            .await?;
231
232        if !response.status().is_success() {
233            let status = response.status().as_u16();
234            let body = response.text().await.unwrap_or_default();
235            return Err(OpenAIAuthError::Http { status, body });
236        }
237
238        let token_response: TokenResponse = response.json().await?;
239        Ok(TokenSet::from(token_response))
240    }
241}
242
243impl Default for OAuthClient {
244    fn default() -> Self {
245        Self::new(OAuthConfig::default()).expect("Failed to create OAuth client with defaults")
246    }
247}