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