Skip to main content

vtcode_auth/openai_chatgpt_oauth/
mod.rs

1//! OpenAI ChatGPT subscription OAuth flow and secure session storage.
2//!
3//! This module implements an OAuth 2.0 PKCE authorization-code flow for ChatGPT
4//! subscription auth, mirroring the flow used by [openai/codex]. By default VT
5//! Code reuses the Codex CLI's **public PKCE OAuth client identity** (no client
6//! secret — the ID is not a secret by OAuth 2.1 design). This is an **unofficial
7//! compatibility mechanism**: OpenAI has not documented or guaranteed third-party
8//! reuse of this client identity, and a public client ID is not authorization
9//! to reuse another tool's OAuth registration. This allows ChatGPT subscription
10//! login to work without the Codex CLI installed.
11//! Organizations with their own OpenAI-issued client can override via
12//! `VTCODE_OPENAI_OAUTH_CLIENT_ID` / `VTCODE_OPENAI_OAUTH_ORIGINATOR`.
13//!
14//! - OAuth authorization-code flow with PKCE
15//! - refresh-token exchange
16//! - token exchange for an OpenAI API-key-style bearer token
17//! - secure storage in keyring or encrypted file storage
18//!
19//! Based on patterns from [openai/codex] (Apache-2.0). Copyright 2025 OpenAI.
20//! See the repository `THIRD-PARTY-NOTICES` file for full attribution.
21//!
22//! [openai/codex]: https://github.com/openai/codex
23
24use anyhow::{Context, Result, anyhow, bail};
25use async_trait::async_trait;
26use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
27use fs2::FileExt;
28use reqwest::Client;
29use ring::rand::{SecureRandom, SystemRandom};
30use serde::{Deserialize, Serialize};
31use std::fmt;
32use std::fs;
33use std::fs::OpenOptions;
34use std::sync::{Arc, Mutex};
35use tokio::sync::Mutex as AsyncMutex;
36
37use crate::storage_paths::auth_storage_dir;
38use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
39
40pub use super::credentials::AuthCredentialsStoreMode;
41use super::pkce::PkceChallenge;
42#[cfg(test)]
43use crate::openai_refresh_policy::extract_error_code;
44use crate::openai_refresh_policy::{RefreshFailureAction, classify_refresh_failure};
45use crate::openai_session_storage::OpenAiSessionStorage;
46#[cfg(test)]
47use crate::openai_session_storage::{
48    decrypt_legacy_session as decrypt_session, encrypt_legacy_session as encrypt_session,
49    legacy_session_path as get_session_path,
50};
51
52const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
53const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
54/// Default OAuth client identity.
55///
56/// This is the **Codex CLI's public PKCE OAuth client ID**. VT Code reuses
57/// Codex's public client identity (a PKCE public client with no client secret
58/// — the ID is not a secret by OAuth 2.1 design) as an **unofficial
59/// compatibility mechanism**. OpenAI has not documented or guaranteed
60/// third-party reuse of this identity, and a public client ID is not
61/// authorization to reuse another tool's OAuth registration. This lets VT
62/// Code perform ChatGPT subscription login without requiring the Codex CLI
63/// to be installed.
64///
65/// Organizations with their own OpenAI-issued OAuth client can override this
66/// via the `VTCODE_OPENAI_OAUTH_CLIENT_ID` environment variable.
67///
68/// See `docs/guides/oauth-authentication.md` for the full explanation.
69const DEFAULT_OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
70/// Default originator sent to OpenAI's authorization endpoint.
71///
72/// This matches the Codex CLI's originator because the default client ID is
73/// Codex's. Override with `VTCODE_OPENAI_OAUTH_ORIGINATOR` when using a custom
74/// client ID.
75const DEFAULT_OPENAI_ORIGINATOR: &str = "codex_cli_rs";
76/// Maximum bytes read from a token-endpoint error response body for
77/// classification. Prevents unbounded reads from a misbehaving or hostile
78/// endpoint while still capturing standard OAuth 2.0 error JSON.
79const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
80const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
81const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
82const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
83const REFRESH_SKEW_SECS: u64 = 60;
84
85/// Resolved OAuth client identity (client ID + originator).
86///
87/// Both fields must be consistent: when a custom client ID is provided via
88/// `VTCODE_OPENAI_OAUTH_CLIENT_ID`, the originator must also be overridden
89/// via `VTCODE_OPENAI_OAUTH_ORIGINATOR`. Sending a custom client ID with
90/// Codex's `codex_cli_rs` originator (or vice versa) would be inconsistent
91/// and is rejected.
92///
93/// `Debug` is safe to derive: the client ID is a public PKCE client
94/// identifier (not a secret by OAuth 2.1 design), and the originator is
95/// a public identifier string.
96#[derive(Debug)]
97struct OAuthClientIdentity {
98    client_id: String,
99    originator: String,
100}
101
102/// Resolve the OAuth client identity from environment variables.
103///
104/// ## Invariant
105///
106/// The client ID and originator form a **coherent pair**. One-sided overrides
107/// are rejected to prevent mixed identities (e.g. a custom client ID paired
108/// with Codex's `codex_cli_rs` originator).
109///
110/// - Both `VTCODE_OPENAI_OAUTH_CLIENT_ID` and `VTCODE_OPENAI_OAUTH_ORIGINATOR`
111///   set and non-blank → use the custom pair.
112/// - Neither set → use the complete Codex default pair.
113/// - Only one set → return a configuration error with an actionable message.
114///   The caller must surface this so the user can fix the environment before
115///   any OAuth request is sent.
116///
117/// All four flow stages (authorization URL, code exchange, refresh, token
118/// exchange) call this resolver, so the same coherent pair is used throughout.
119fn resolve_oauth_client_identity() -> Result<OAuthClientIdentity> {
120    let custom_client_id = std::env::var("VTCODE_OPENAI_OAUTH_CLIENT_ID")
121        .ok()
122        .filter(|v| !v.trim().is_empty());
123    let custom_originator = std::env::var("VTCODE_OPENAI_OAUTH_ORIGINATOR")
124        .ok()
125        .filter(|v| !v.trim().is_empty());
126
127    match (custom_client_id, custom_originator) {
128        (Some(id), Some(originator)) => Ok(OAuthClientIdentity { client_id: id, originator }),
129        (Some(_), None) => bail!(
130            "VTCODE_OPENAI_OAUTH_CLIENT_ID is set but VTCODE_OPENAI_OAUTH_ORIGINATOR is not. \
131             The client ID and originator must be overridden together to form a coherent OAuth \
132             identity. Set VTCODE_OPENAI_OAUTH_ORIGINATOR to match your custom client ID, \
133             or unset VTCODE_OPENAI_OAUTH_CLIENT_ID to use the default Codex identity."
134        ),
135        (None, Some(_)) => bail!(
136            "VTCODE_OPENAI_OAUTH_ORIGINATOR is set but VTCODE_OPENAI_OAUTH_CLIENT_ID is not. \
137             The client ID and originator must be overridden together to form a coherent OAuth \
138             identity. Set VTCODE_OPENAI_OAUTH_CLIENT_ID to match your custom originator, \
139             or unset VTCODE_OPENAI_OAUTH_ORIGINATOR to use the default Codex identity."
140        ),
141        (None, None) => Ok(OAuthClientIdentity {
142            client_id: DEFAULT_OPENAI_CLIENT_ID.to_string(),
143            originator: DEFAULT_OPENAI_ORIGINATOR.to_string(),
144        }),
145    }
146}
147
148mod jwt;
149mod refresh;
150mod session;
151
152pub(crate) use jwt::{parse_jwt_claims, parse_jwt_exp};
153pub use refresh::{
154    clear_openai_chatgpt_session, clear_openai_chatgpt_session_with_mode, exchange_openai_chatgpt_code_for_tokens,
155    get_openai_chatgpt_auth_status, get_openai_chatgpt_auth_status_with_mode, load_openai_chatgpt_session,
156    load_openai_chatgpt_session_with_mode, refresh_openai_chatgpt_session_with_mode, save_openai_chatgpt_session,
157    save_openai_chatgpt_session_with_mode,
158};
159pub use session::{
160    OpenAIChatGptAuthHandle, OpenAIChatGptAuthStatus, OpenAIChatGptSession, OpenAIChatGptSessionProvenance,
161    OpenAIChatGptSessionRefresher, OpenAICredentialOverview, OpenAIResolvedAuth, OpenAIResolvedAuthSource,
162    generate_openai_oauth_state, get_openai_chatgpt_auth_url, parse_openai_chatgpt_manual_callback_input,
163    resolve_openai_auth, summarize_openai_credentials,
164};
165
166#[cfg(test)]
167pub(crate) use refresh::{
168    OpenAIRefreshResponse, acquire_refresh_lock, classify_refresh_status_error, merge_refresh_response,
169};
170#[cfg(test)]
171pub(crate) use session::active_api_bearer_token;
172
173/// Current Unix time in seconds, saturating to `0` before the epoch.
174pub(super) fn now_secs() -> u64 {
175    std::time::SystemTime::now()
176        .duration_since(std::time::UNIX_EPOCH)
177        .map(|duration| duration.as_secs())
178        .unwrap_or(0)
179}
180
181#[cfg(test)]
182mod tests;