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(¶ms)
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(¶ms)
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}