mirror of
https://github.com/openai/codex.git
synced 2026-05-04 05:11:37 +03:00
Add device-code onboarding and ChatGPT token refresh to app-server TUI (#14952)
## Summary - add device-code ChatGPT sign-in to `tui_app_server` onboarding and reuse the existing `chatgptAuthTokens` login path - fall back to browser login when device-code auth is unavailable on the server - treat `ChatgptAuthTokens` as an existing signed-in ChatGPT state during onboarding - add a local ChatGPT auth loader for handing local tokens to the app server and serving refresh requests - handle `account/chatgptAuthTokens/refresh` instead of marking it unsupported, including workspace/account mismatch checks - add focused coverage for onboarding success, existing auth handling, local auth loading, and refresh request behavior ## Testing - `cargo test -p codex-tui-app-server` - `just fix -p codex-tui-app-server`
This commit is contained in:
195
codex-rs/tui_app_server/src/local_chatgpt_auth.rs
Normal file
195
codex-rs/tui_app_server/src/local_chatgpt_auth.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
|
||||
use codex_core::auth::AuthCredentialsStoreMode;
|
||||
use codex_core::auth::load_auth_dot_json;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LocalChatgptAuth {
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) chatgpt_account_id: String,
|
||||
pub(crate) chatgpt_plan_type: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalChatgptAuth {
|
||||
pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse {
|
||||
ChatgptAuthTokensRefreshResponse {
|
||||
access_token: self.access_token.clone(),
|
||||
chatgpt_account_id: self.chatgpt_account_id.clone(),
|
||||
chatgpt_plan_type: self.chatgpt_plan_type.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_chatgpt_auth(
|
||||
codex_home: &Path,
|
||||
auth_credentials_store_mode: AuthCredentialsStoreMode,
|
||||
forced_chatgpt_workspace_id: Option<&str>,
|
||||
) -> Result<LocalChatgptAuth, String> {
|
||||
let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode)
|
||||
.map_err(|err| format!("failed to load local auth: {err}"))?
|
||||
.ok_or_else(|| "no local auth available".to_string())?;
|
||||
if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {
|
||||
return Err("local auth is not a ChatGPT login".to_string());
|
||||
}
|
||||
|
||||
let tokens = auth
|
||||
.tokens
|
||||
.ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?;
|
||||
let access_token = tokens.access_token;
|
||||
let chatgpt_account_id = tokens
|
||||
.account_id
|
||||
.or(tokens.id_token.chatgpt_account_id.clone())
|
||||
.ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?;
|
||||
if let Some(expected_workspace) = forced_chatgpt_workspace_id
|
||||
&& chatgpt_account_id != expected_workspace
|
||||
{
|
||||
return Err(format!(
|
||||
"local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}"
|
||||
));
|
||||
}
|
||||
|
||||
let chatgpt_plan_type = tokens
|
||||
.id_token
|
||||
.get_chatgpt_plan_type()
|
||||
.map(|plan_type| plan_type.to_ascii_lowercase());
|
||||
|
||||
Ok(LocalChatgptAuth {
|
||||
access_token,
|
||||
chatgpt_account_id,
|
||||
chatgpt_plan_type,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_core::auth::AuthDotJson;
|
||||
use codex_core::auth::login_with_chatgpt_auth_tokens;
|
||||
use codex_core::auth::save_auth;
|
||||
use codex_core::token_data::TokenData;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
|
||||
#[derive(Serialize)]
|
||||
struct Header {
|
||||
alg: &'static str,
|
||||
typ: &'static str,
|
||||
}
|
||||
|
||||
let header = Header {
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
};
|
||||
let payload = json!({
|
||||
"email": email,
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
"chatgpt_plan_type": plan_type,
|
||||
},
|
||||
});
|
||||
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||||
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
|
||||
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
|
||||
let signature_b64 = encode(b"sig");
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn write_chatgpt_auth(codex_home: &Path) {
|
||||
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
|
||||
.expect("id token should parse"),
|
||||
access_token,
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
account_id: Some("workspace-1".to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
};
|
||||
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
|
||||
.expect("chatgpt auth should save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_local_chatgpt_auth_from_managed_auth() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
Some("workspace-1"),
|
||||
)
|
||||
.expect("chatgpt auth should load");
|
||||
|
||||
assert_eq!(auth.chatgpt_account_id, "workspace-1");
|
||||
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
|
||||
assert!(!auth.access_token.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_local_auth() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
|
||||
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
|
||||
.expect_err("missing auth should fail");
|
||||
|
||||
assert_eq!(err, "no local auth available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_api_key_auth() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&AuthDotJson {
|
||||
auth_mode: Some(AuthMode::ApiKey),
|
||||
openai_api_key: Some("sk-test".to_string()),
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
},
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("api key auth should save");
|
||||
|
||||
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
|
||||
.expect_err("api key auth should fail");
|
||||
|
||||
assert_eq!(err, "local auth is not a ChatGPT login");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_managed_auth_over_external_ephemeral_tokens() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
login_with_chatgpt_auth_tokens(
|
||||
codex_home.path(),
|
||||
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
|
||||
"workspace-2",
|
||||
Some("enterprise"),
|
||||
)
|
||||
.expect("external auth should save");
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
Some("workspace-1"),
|
||||
)
|
||||
.expect("managed auth should win");
|
||||
|
||||
assert_eq!(auth.chatgpt_account_id, "workspace-1");
|
||||
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user