Skip to content

Commit d87a6ff

Browse files
tivrisautofix-ci[bot]amitksingh1490forge-code-agent
authored
feat(auth): add OAuth authorization code + PKCE for Codex provider (#2790)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Amit Singh <amitksingh1490@gmail.com> Co-authored-by: ForgeCode <noreply@forgecode.dev>
1 parent 6b3e16f commit d87a6ff

File tree

10 files changed

+691
-57
lines changed

10 files changed

+691
-57
lines changed

Cargo.lock

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ syn = { version = "2.0.117", features = ["derive", "parsing"] }
8989
sysinfo = "0.38.3"
9090
tempfile = "3.27.0"
9191
termimad = "0.34.1"
92+
tiny_http = "0.12.0"
9293
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
9394
thiserror = "2.0.18"
9495
toml_edit = { version = "0.22", features = ["serde"] }

crates/forge_domain/src/auth/auth_token_response.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ pub struct OAuthTokenResponse {
2626
/// OAuth scopes granted
2727
#[serde(skip_serializing_if = "Option::is_none")]
2828
pub scope: Option<String>,
29+
30+
/// ID token containing user identity claims (OpenID Connect)
31+
#[serde(skip_serializing_if = "Option::is_none")]
32+
pub id_token: Option<String>,
2933
}
3034

3135
fn default_token_type() -> String {

crates/forge_infra/src/auth/strategy.rs

Lines changed: 84 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,68 @@ impl AuthStrategy for ApiKeyStrategy {
5757
}
5858
}
5959

60+
/// Extract the ChatGPT account ID from a JWT token's claims.
61+
///
62+
/// Checks `chatgpt_account_id`, `https://api.openai.com/auth.chatgpt_account_id`,
63+
/// and `organizations[0].id` in that order, matching the opencode
64+
/// implementation.
65+
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
66+
let parts: Vec<&str> = token.split('.').collect();
67+
if parts.len() != 3 {
68+
return None;
69+
}
70+
use base64::Engine;
71+
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
72+
.decode(parts[1])
73+
.ok()?;
74+
let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?;
75+
76+
// Try chatgpt_account_id first
77+
if let Some(id) = claims["chatgpt_account_id"].as_str() {
78+
return Some(id.to_string());
79+
}
80+
// Try nested auth claim
81+
if let Some(id) = claims["https://api.openai.com/auth"]["chatgpt_account_id"].as_str() {
82+
return Some(id.to_string());
83+
}
84+
// Fall back to organizations[0].id
85+
if let Some(id) = claims["organizations"]
86+
.as_array()
87+
.and_then(|orgs| orgs.first())
88+
.and_then(|org| org["id"].as_str())
89+
{
90+
return Some(id.to_string());
91+
}
92+
None
93+
}
94+
95+
/// Adds Codex-specific credential metadata derived from OAuth tokens.
96+
///
97+
/// Tries to extract the account ID from the `id_token` first (which typically
98+
/// contains the user identity claims in OpenID Connect flows), then falls back
99+
/// to the `access_token` if needed.
100+
fn enrich_codex_oauth_credential(
101+
provider_id: &ProviderId,
102+
credential: &mut AuthCredential,
103+
id_token: Option<&str>,
104+
access_token: &str,
105+
) {
106+
if *provider_id != ProviderId::CODEX {
107+
return;
108+
}
109+
110+
// Try id_token first (preferred for user identity claims)
111+
let account_id = id_token
112+
.and_then(extract_chatgpt_account_id)
113+
.or_else(|| extract_chatgpt_account_id(access_token));
114+
115+
if let Some(account_id) = account_id {
116+
credential
117+
.url_params
118+
.insert("chatgpt_account_id".to_string().into(), account_id.into());
119+
}
120+
}
121+
60122
/// OAuth Code Strategy - Browser redirect flow
61123
pub struct OAuthCodeStrategy<T> {
62124
provider_id: ProviderId,
@@ -96,7 +158,7 @@ impl<T: OAuthHttpProvider> AuthStrategy for OAuthCodeStrategy<T> {
96158
let token_response = self
97159
.adapter
98160
.exchange_code(
99-
&self.config,
161+
&ctx.request.oauth_config,
100162
ctx.response.code.as_str(),
101163
ctx.request.pkce_verifier.as_ref().map(|v| v.as_str()),
102164
)
@@ -107,12 +169,21 @@ impl<T: OAuthHttpProvider> AuthStrategy for OAuthCodeStrategy<T> {
107169
))
108170
})?;
109171

110-
build_oauth_credential(
172+
let access_token = token_response.access_token.clone();
173+
let id_token = token_response.id_token.clone();
174+
let mut credential = build_oauth_credential(
111175
self.provider_id.clone(),
112176
token_response,
113-
&self.config,
177+
&ctx.request.oauth_config,
114178
chrono::Duration::hours(1), // Code flow default
115-
)
179+
)?;
180+
enrich_codex_oauth_credential(
181+
&self.provider_id,
182+
&mut credential,
183+
id_token.as_deref(),
184+
&access_token,
185+
);
186+
Ok(credential)
116187
}
117188
_ => Err(AuthError::InvalidContext("Expected Code context".to_string()).into()),
118189
}
@@ -479,41 +550,6 @@ struct CodexDeviceTokenResponse {
479550
code_verifier: String,
480551
}
481552

482-
/// Extract the ChatGPT account ID from a JWT token's claims.
483-
///
484-
/// Checks `chatgpt_account_id`, `https://api.openai.com/auth.chatgpt_account_id`,
485-
/// and `organizations[0].id` in that order, matching the opencode
486-
/// implementation.
487-
fn extract_chatgpt_account_id(token: &str) -> Option<String> {
488-
let parts: Vec<&str> = token.split('.').collect();
489-
if parts.len() != 3 {
490-
return None;
491-
}
492-
use base64::Engine;
493-
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
494-
.decode(parts[1])
495-
.ok()?;
496-
let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?;
497-
498-
// Try chatgpt_account_id first
499-
if let Some(id) = claims["chatgpt_account_id"].as_str() {
500-
return Some(id.to_string());
501-
}
502-
// Try nested auth claim
503-
if let Some(id) = claims["https://api.openai.com/auth"]["chatgpt_account_id"].as_str() {
504-
return Some(id.to_string());
505-
}
506-
// Fall back to organizations[0].id
507-
if let Some(id) = claims["organizations"]
508-
.as_array()
509-
.and_then(|orgs| orgs.first())
510-
.and_then(|org| org["id"].as_str())
511-
{
512-
return Some(id.to_string());
513-
}
514-
None
515-
}
516-
517553
#[async_trait::async_trait]
518554
impl AuthStrategy for CodexDeviceStrategy {
519555
async fn init(&self) -> anyhow::Result<AuthContextRequest> {
@@ -570,11 +606,8 @@ impl AuthStrategy for CodexDeviceStrategy {
570606
// Poll for authorization code using the custom OpenAI endpoint
571607
let token_response = codex_poll_for_tokens(&ctx.request, &self.config).await?;
572608

573-
// Extract ChatGPT account ID from the access token JWT.
574-
// This is used for the optional `ChatGPT-Account-Id` request
575-
// header when available.
576-
let account_id = extract_chatgpt_account_id(&token_response.access_token);
577-
609+
let access_token = token_response.access_token.clone();
610+
let id_token = token_response.id_token.clone();
578611
let mut credential = build_oauth_credential(
579612
self.provider_id.clone(),
580613
token_response,
@@ -583,12 +616,13 @@ impl AuthStrategy for CodexDeviceStrategy {
583616
)?;
584617

585618
// Store account_id in url_params so it's persisted and available
586-
// for chat request headers
587-
if let Some(id) = account_id {
588-
credential
589-
.url_params
590-
.insert("chatgpt_account_id".to_string().into(), id.into());
591-
}
619+
// for chat request headers.
620+
enrich_codex_oauth_credential(
621+
&self.provider_id,
622+
&mut credential,
623+
id_token.as_deref(),
624+
&access_token,
625+
);
592626

593627
Ok(credential)
594628
}

crates/forge_infra/src/auth/util.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub(crate) fn into_domain<T: oauth2::TokenResponse>(token: T) -> OAuthTokenRespo
3636
.collect::<Vec<_>>()
3737
.join(" ")
3838
}),
39+
id_token: None, // oauth2 crate doesn't provide id_token directly
3940
}
4041
}
4142

@@ -98,6 +99,7 @@ pub(crate) fn build_token_response(
9899
expires_at: None,
99100
token_type: "Bearer".to_string(),
100101
scope: None,
102+
id_token: None,
101103
}
102104
}
103105

crates/forge_main/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ strip-ansi-escapes.workspace = true
6666
terminal_size = "0.4"
6767
rustls.workspace = true
6868
tempfile.workspace = true
69+
tiny_http.workspace = true
6970

7071
[target.'cfg(windows)'.dependencies]
7172
enable-ansi-support.workspace = true

crates/forge_main/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod editor;
77
mod info;
88
mod input;
99
mod model;
10+
mod oauth_callback;
1011
mod porcelain;
1112
mod prompt;
1213
mod sandbox;

0 commit comments

Comments
 (0)