Restrict MCP user verification and add workspace-scoped identity (#43524)

## Why

Configured MCP servers must not be able to request user verification, even when they reuse the hosted apps server name. Local verification identity selection also needs to distinguish a user's workspace memberships.

## What changed

- Advertise `userVerification` only to the host-owned apps MCP server and cancel verification requests from other servers before prompting the client.
- Add `CodexAuth::get_chatgpt_account_user_id()` to read the access token's opaque `chatgpt_account_user_id` only when its workspace matches the selected account. Missing or malformed claims return no identity, without falling back to another user ID or breaking ordinary authentication.

## Testing

Add coverage for capability filtering, rejection of configured servers including those using the hosted apps name, and account-user identity selection with malformed or mismatched claims. Add an MCP round-trip test showing that verification waits for a client response even in full access mode, returns the supplied proof, and discards response metadata.

GitOrigin-RevId: 156e83d5a0302c013b85e564b46b4bfe858cbdb0
This commit is contained in:
riley-oai
2026-09-07 17:26:10 +00:00
committed by copyberry
parent adee0b04fa
commit f326857cf4
11 changed files with 587 additions and 68 deletions
@@ -132,3 +132,4 @@ mod web_search;
mod windows_sandbox_setup;
mod user_verification;
mod user_verification_mcp;
@@ -0,0 +1,218 @@
//! Exercises the MCP-to-core ceremony without depending on biometric hardware.
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use axum::Router;
use codex_config::Constrained;
use codex_core::StartThreadOptions;
use codex_core::config::Config;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::McpServerContributor;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use core_test_support::apps_test_server::apps_enabled_builder;
use core_test_support::responses::start_mock_server;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use rmcp::ServerHandler;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResponse;
use rmcp::model::CallToolResult;
use rmcp::model::ContentBlock;
use rmcp::model::CustomRequest;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::ServerRequest;
use rmcp::service::RequestContext;
use rmcp::service::RoleServer;
use rmcp::transport::StreamableHttpServerConfig;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use serde_json::Value;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio::time::timeout;
use tokio_util::task::AbortOnDropHandle;
#[derive(Clone)]
struct VerificationServer {
response: Arc<Mutex<Option<Value>>>,
}
struct HostedVerificationServer(codex_config::McpServerConfig);
impl McpServerContributor<Config> for HostedVerificationServer {
fn id(&self) -> &'static str {
"user-verification-integration-test"
}
fn contribute<'a>(
&'a self,
_context: McpServerContributionContext<'a, Config>,
) -> ExtensionFuture<'a, Vec<McpServerContribution>> {
Box::pin(async move {
vec![McpServerContribution::HostedApps {
config: Box::new(self.0.clone()),
}]
})
}
}
impl ServerHandler for VerificationServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
async fn call_tool(
&self,
_request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, rmcp::ErrorData> {
assert_eq!(
context
.peer
.peer_info()
.and_then(|info| info.capabilities.extensions.clone())
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID).cloned())
.map(Value::Object),
Some(json!({"userVerification": {}})),
);
let result = context
.peer
.send_request(ServerRequest::CustomRequest(CustomRequest::new(
"openai/elicitation/create",
Some(json!({
"mode": "openai/userVerification",
"title": "Approve purchase",
"description": "Pay $200 to Example Store",
"challenge": "AAECA_7_",
})),
)))
.await
.map_err(|error| {
rmcp::ErrorData::internal_error(error.to_string(), /*data*/ None)
})?;
*self.response.lock().await = Some(serde_json::to_value(result).map_err(|error| {
rmcp::ErrorData::internal_error(error.to_string(), /*data*/ None)
})?);
Ok(CallToolResult::success(vec![ContentBlock::text("verified")]).into())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn user_verification_mcp_round_trip_requires_proof_in_full_access() -> Result<()> {
let received = Arc::new(Mutex::new(None));
let server = VerificationServer {
response: Arc::clone(&received),
};
let service = StreamableHttpService::new(
move || Ok(server.clone()),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let listener = TcpListener::bind("127.0.0.1:0").await?;
let base_url = format!("http://{}", listener.local_addr()?);
let router = Router::new().nest_service("/api/codex/ps/mcp", service);
let _server = AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
}));
let model_server = start_mock_server().await;
let mut extensions = ExtensionRegistryBuilder::new();
let plugin_service_config = serde_json::from_value(json!({
"url": format!("{base_url}/api/codex/ps/mcp"),
}))?;
extensions.mcp_server_contributor(Arc::new(HostedVerificationServer(plugin_service_config)));
let test = apps_enabled_builder(base_url)
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
config
.permissions
.set_permission_profile(PermissionProfile::Disabled)
.unwrap();
})
.build_with_auto_env(&model_server)
.await?;
// Supply the trusted host capability through the public core API. The production
// app-server only activates it for supported in-process UIs with biometric hardware.
let thread = test
.thread_manager
.start_thread(StartThreadOptions {
client_mcp_extensions: ClientMcpExtensions::new([(
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
json!({"userVerification": {}}),
)]),
environments: Some(vec![test.executor_environment().selection().clone()]),
..StartThreadOptions::new(test.config.clone())
})
.await?
.thread;
let caller = Arc::clone(&thread);
let call = AbortOnDropHandle::new(tokio::spawn(async move {
caller
.call_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"verify_action",
/*arguments*/ None,
/*meta*/ None,
)
.await
}));
let EventMsg::ElicitationRequest(request) = wait_for_event(&thread, |event| {
matches!(event, EventMsg::ElicitationRequest(_))
})
.await
else {
unreachable!()
};
assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(
request.request,
ElicitationRequest::UserVerification {
title: "Approve purchase".into(),
description: "Pay $200 to Example Store".into(),
challenge: "AAECA_7_".into(),
}
);
assert!(received.lock().await.is_none());
assert!(!call.is_finished());
let proof = json!({"credentialId": "AQID", "signature": "BAUG"});
thread
.submit(Op::ResolveElicitation {
server_name: request.server_name,
request_id: request.id,
decision: ElicitationAction::Accept,
content: Some(proof.clone()),
meta: Some(json!({"untrusted": "discarded"})),
})
.await?;
assert_eq!(
timeout(Duration::from_secs(/*secs*/ 10), call).await???,
codex_protocol::mcp::CallToolResult {
content: vec![json!({"type": "text", "text": "verified"})],
structured_content: None,
is_error: Some(false),
meta: None,
},
);
assert_eq!(
*received.lock().await,
Some(json!({"action": "accept", "content": proof})),
);
thread.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
Ok(())
}
@@ -8,6 +8,29 @@ use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID;
use serde_json::Map;
use serde_json::Value;
/// Restricts verification to the host-owned plugin service, even when another server uses its name.
pub(crate) fn server_mcp_extensions(
extensions: &ClientMcpExtensions,
server_name: &str,
server: Option<&crate::catalog::ResolvedMcpServer>,
) -> ClientMcpExtensions {
let plugin_service = server.is_some_and(|server| {
server
.source()
.is_host_owned_apps(server_name, server.config())
});
ClientMcpExtensions::new(extensions.iter().map(|(id, settings)| {
let mut settings = settings.clone();
if !plugin_service
&& id == OPENAI_ELICITATION_EXTENSION_ID
&& let Some(settings) = settings.as_object_mut()
{
settings.remove("userVerification");
}
(id.to_string(), settings)
}))
}
/// Selects the MCP extensions Codex supports from those declared by the app-server host.
///
/// App-server clients may declare unrelated extensions. Codex retains only the
@@ -61,3 +61,50 @@ fn normalizes_legacy_form_capability_into_extensions() {
)]))
);
}
#[test]
fn user_verification_is_projected_only_to_the_host_owned_plugin_service() {
use crate::catalog::McpServerRegistration;
use crate::catalog::ResolvedMcpCatalog;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::codex_apps_mcp_server_config;
let config = codex_apps_mcp_server_config(
"https://example.com",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
);
let extensions = ClientMcpExtensions::new([(
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
json!({"form": {}, "userVerification": {}}),
)]);
for (name, registration, settings) in [
(
CODEX_APPS_MCP_SERVER_NAME,
McpServerRegistration::from_hosted_apps(
"host",
/*contribution_order*/ 0,
config.clone(),
),
json!({"form": {}, "userVerification": {}}),
),
(
CODEX_APPS_MCP_SERVER_NAME,
McpServerRegistration::from_config(CODEX_APPS_MCP_SERVER_NAME.into(), config.clone()),
json!({"form": {}}),
),
(
"attached",
McpServerRegistration::from_config("attached".into(), config),
json!({"form": {}}),
),
] {
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(registration);
let catalog = catalog.build();
assert_eq!(
server_mcp_extensions(&extensions, name, catalog.server(name)),
ClientMcpExtensions::new([(OPENAI_ELICITATION_EXTENSION_ID.to_string(), settings)]),
);
}
}
@@ -288,6 +288,11 @@ impl McpConnectionSet {
.filter(|(_, server)| server.enabled())
{
let registration = config.mcp_server_catalog.server(&server_name);
let client_mcp_extensions = crate::client_capabilities::server_mcp_extensions(
&client_mcp_extensions,
&server_name,
registration,
);
let is_host_owned_codex_apps = registration.is_some_and(|server| {
server
.source()
@@ -587,3 +587,52 @@ async fn user_verification_drops_pending_response_when_the_request_is_cancelled(
assert!(pending.await.unwrap_err().is_cancelled());
assert!(manager.router.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn user_verification_rejects_attached_servers_even_if_they_use_the_plugin_service_name() {
for name in ["attached", crate::CODEX_APPS_MCP_SERVER_NAME] {
let (manager, _, _) =
verification_fixture(AskForApproval::OnRequest, /*reviewer*/ None);
{
let mut authority = manager.authority.lock().unwrap();
let config = Arc::make_mut(&mut authority.as_mut().unwrap().config);
let mut catalog = crate::catalog::ResolvedMcpCatalog::builder();
catalog.register(crate::catalog::McpServerRegistration::from_config(
name.into(),
crate::mcp::codex_apps_mcp_server_config(
"https://example.com",
/*apps_mcp_product_sku*/ None,
/*originator*/ None,
),
));
config.mcp_server_catalog = catalog.build();
}
let (tx, events) = async_channel::bounded(1);
let sender = manager.make_sender(
name.into(),
Some(tx),
&ClientMcpExtensions::new([(
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
json!({"userVerification": {}}),
)]),
);
assert_eq!(
sender(
RequestId::Number(7),
Elicitation::UserVerification {
title: "Approve".into(),
description: String::new(),
challenge: "AQID".into()
}
)
.await
.unwrap(),
ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
meta: None
},
);
assert!(events.try_recv().is_err());
}
}
@@ -13,7 +13,18 @@ pub(super) async fn route(
.lock()
.ok()
.and_then(|authority| authority.clone());
let Some(events) = events.filter(|_| !router.auto_deny()) else {
let plugin_service = authority.as_ref().is_some_and(|authority| {
authority
.config
.mcp_server_catalog
.server(&server_name)
.is_some_and(|server| {
server
.source()
.is_host_owned_apps(&server_name, server.config())
})
});
let Some(events) = events.filter(|_| plugin_service && !router.auto_deny()) else {
return Ok(ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
@@ -1,19 +1,17 @@
//! Typed verification holds the enclosing tool until the client answers the request.
//! Configured MCP servers cannot activate verification or hold a turn waiting for proof.
use anyhow::Result;
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use codex_protocol::approvals::ElicitationRequest;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
use codex_protocol::protocol::ElicitationAction;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -68,9 +66,9 @@ enum CapabilitySource {
}
#[test_case(CapabilitySource::HostProjection; "host_projection_does_not_advertise_verification")]
#[test_case(CapabilitySource::ExplicitSession; "explicit_session_routes_typed_verification")]
#[test_case(CapabilitySource::ExplicitSession; "explicit_session_cannot_enable_configured_server")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_user_verification_transport(source: CapabilitySource) -> Result<()> {
async fn mcp_user_verification_rejects_configured_servers(source: CapabilitySource) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(Ok(()), "the MCP fixture requires a host Python interpreter");
let server = responses::start_mock_server().await;
@@ -84,7 +82,7 @@ async fn mcp_user_verification_transport(source: CapabilitySource) -> Result<()>
Some(&declarations),
/*legacy_openai_form_elicitation*/ false,
),
// Exercise the typed route when the host directly supplies this capability.
// Even an explicitly enabled session cannot grant this capability to a configured server.
CapabilitySource::ExplicitSession => ClientMcpExtensions::new(declarations),
};
let mut config = test.config.clone();
@@ -131,47 +129,14 @@ async fn mcp_user_verification_transport(source: CapabilitySource) -> Result<()>
text_elements: Vec::new(),
}]))
.await?;
let mut prompted = false;
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 10), async {
loop {
match thread.next_event().await?.msg {
EventMsg::ElicitationRequest(request) => {
assert!(matches!(source, CapabilitySource::ExplicitSession));
assert!(!prompted, "verification must only prompt once");
assert_eq!(
request.request,
ElicitationRequest::UserVerification {
title: "Approve".into(),
description: "".into(),
challenge: "AQID".into(),
}
);
assert!(
follow_up.requests().is_empty(),
"the tool must wait for the response"
);
thread
.submit(Op::ResolveElicitation {
server_name: request.server_name,
request_id: request.id,
decision: ElicitationAction::Cancel,
content: None,
meta: None,
})
.await?;
prompted = true;
}
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
anyhow::Ok(())
wait_for_event(&thread, |event| {
assert!(
!matches!(event, EventMsg::ElicitationRequest(_)),
"configured servers must not prompt for verification"
);
matches!(event, EventMsg::TurnComplete(_))
})
.await??;
assert_eq!(
prompted,
matches!(source, CapabilitySource::ExplicitSession)
);
.await;
let output = follow_up
.single_request()
.function_call_output("verify-call");
@@ -180,26 +145,15 @@ async fn mcp_user_verification_transport(source: CapabilitySource) -> Result<()>
.as_str()
.expect("MCP wire response"),
)?;
match source {
CapabilitySource::HostProjection => {
assert!(
echoed["extensions"][OPENAI_ELICITATION_EXTENSION_ID]
.get("userVerification")
.is_none()
);
assert_eq!(
echoed["response"],
json!({"code": -32601, "message": "openai/elicitation/create"})
);
}
CapabilitySource::ExplicitSession => {
assert_eq!(
echoed["extensions"][OPENAI_ELICITATION_EXTENSION_ID],
json!({"userVerification": {}})
);
assert_eq!(echoed["response"], json!({"action": "cancel"}));
}
}
assert!(
echoed["extensions"][OPENAI_ELICITATION_EXTENSION_ID]
.get("userVerification")
.is_none()
);
assert_eq!(
echoed["response"],
json!({"code": -32601, "message": "openai/elicitation/create"})
);
thread.shutdown_and_wait().await?;
test.codex.shutdown_and_wait().await?;
Ok(())
@@ -0,0 +1,156 @@
//! Local account-user identity selection must use the access token's selected workspace.
use super::*;
use base64::Engine;
use pretty_assertions::assert_eq;
use serde_json::json;
fn access_token(auth_claims: Value) -> String {
let payload = json!({"https://api.openai.com/auth": auth_claims});
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap());
format!("e30.{payload}.c2ln")
}
fn managed_auth(access_token: String, selected_account: Option<&str>) -> CodexAuth {
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let CodexAuth::Chatgpt(managed) = &auth else {
panic!("expected managed ChatGPT auth");
};
let mut state = managed.state.auth_dot_json.lock().unwrap();
let tokens = state.as_mut().unwrap().tokens.as_mut().unwrap();
tokens.access_token = access_token;
tokens.account_id = selected_account.map(str::to_owned);
tokens.id_token.chatgpt_user_id = Some("different-id-token-user".to_string());
drop(state);
auth
}
#[test]
fn account_user_id_comes_from_the_access_token_without_synthesis() {
let auth = managed_auth(
access_token(json!({
"chatgpt_account_user_id": "opaque-membership",
"chatgpt_account_id": "workspace-a",
"chatgpt_user_id": "chatgpt-user",
"user_id": "auth-user",
})),
Some("workspace-a"),
);
assert_eq!(
auth.get_chatgpt_account_user_id(),
Some("opaque-membership".to_string())
);
}
#[test]
fn account_user_id_rejects_missing_malformed_or_unscoped_claims() {
for claims in [
json!(null),
json!({"user_id": "auth-user", "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_user_id": "chatgpt-user", "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_account_user_id": null, "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_account_user_id": "", "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_account_user_id": " membership ", "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_account_user_id": 123, "chatgpt_account_id": "workspace-a"}),
json!({"chatgpt_account_user_id": "membership"}),
json!({"chatgpt_account_user_id": "membership", "chatgpt_account_id": "workspace-b"}),
json!({"chatgpt_account_user_id": "membership", "chatgpt_account_id": 123}),
] {
let auth = managed_auth(access_token(claims.clone()), Some("workspace-a"));
assert_eq!(auth.get_chatgpt_account_user_id(), None, "{claims}");
}
for token in [
"not-a-jwt".to_string(),
"e30.!.c2ln".to_string(),
format!(
"{}.extra",
access_token(json!({
"chatgpt_account_user_id": "membership",
"chatgpt_account_id": "workspace-a",
}))
),
] {
let auth = managed_auth(token, Some("workspace-a"));
assert_eq!(auth.get_chatgpt_account_user_id(), None);
}
}
#[test]
fn account_user_id_requires_a_selected_account() {
for selected_account in [None, Some(""), Some(" workspace-a ")] {
let auth = managed_auth(
access_token(json!({
"chatgpt_account_user_id": "membership",
"chatgpt_account_id": "workspace-a",
})),
selected_account,
);
assert_eq!(auth.get_chatgpt_account_user_id(), None);
}
}
#[test]
fn external_account_user_id_must_match_the_selected_workspace() {
let token = access_token(json!({
"chatgpt_account_user_id": "membership-a",
"chatgpt_account_id": "workspace-a",
}));
for (selected_account, expected) in [
("workspace-a", Some("membership-a".to_string())),
("workspace-b", None),
] {
let auth = CodexAuth::from_external_chatgpt_tokens(
&token,
selected_account,
/*chatgpt_plan_type*/ None,
)
.unwrap();
assert_eq!(auth.get_chatgpt_account_user_id(), expected);
}
}
#[test]
fn malformed_account_user_id_does_not_break_ordinary_external_auth() {
let token = access_token(json!({
"chatgpt_account_user_id": 123,
"chatgpt_account_id": "workspace-a",
"chatgpt_user_id": "chatgpt-user",
}));
let auth = CodexAuth::from_external_chatgpt_tokens(
&token,
"workspace-a",
/*chatgpt_plan_type*/ None,
)
.unwrap();
assert_eq!(
(
auth.get_chatgpt_account_user_id(),
auth.get_chatgpt_user_id(),
auth.get_account_id(),
),
(
None,
Some("chatgpt-user".to_string()),
Some("workspace-a".to_string()),
)
);
}
#[test]
fn api_key_and_header_auth_do_not_expose_an_account_user_id() {
let mut headers = http::HeaderMap::new();
headers.insert(
"chatgpt-account-id",
http::HeaderValue::from_static("workspace-a"),
);
for auth in [
CodexAuth::from_api_key("test-api-key"),
CodexAuth::Headers(AuthHeaders::new(headers)),
] {
assert_eq!(auth.get_chatgpt_account_user_id(), None);
}
}
+18
View File
@@ -59,6 +59,7 @@ use crate::default_client::create_client;
use crate::default_client::create_default_auth_client;
use crate::outbound_proxy::AuthRouteConfig;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_account_user_id;
use crate::token_data::parse_chatgpt_jwt_claims;
use crate::token_data::parse_jwt_expiration;
use codex_config::ManagedAuthPolicy;
@@ -631,6 +632,19 @@ impl CodexAuth {
}
}
/// Returns the access token's opaque account-user identity only when its workspace
/// matches the selected account. Missing claims never fall back to a user id.
/// Unlike `get_chatgpt_user_id`, this identifies one workspace membership, so keys
/// are not shared across a person's workspaces. Workload-identity exchange claims
/// describe an agent identity and cannot select a human verification credential.
/// This is local identity selection, not access-token or proof validation.
pub fn get_chatgpt_account_user_id(&self) -> Option<String> {
let tokens = self.get_current_token_data()?;
parse_chatgpt_account_user_id(&tokens.access_token, tokens.account_id.as_deref()?)
.ok()
.flatten()
}
/// Account-facing plan classification derived from the current auth.
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// for UI or product decisions based on the user's subscription.
@@ -3070,3 +3084,7 @@ mod tests;
#[cfg(test)]
#[path = "change_state_tests.rs"]
mod change_state_tests;
#[cfg(test)]
#[path = "account_user_id_tests.rs"]
mod account_user_id_tests;
+37
View File
@@ -104,6 +104,18 @@ struct StandardJwtClaims {
exp: Option<i64>,
}
#[derive(Deserialize)]
struct AccountUserClaims {
#[serde(rename = "https://api.openai.com/auth")]
auth: Option<AccountUserAuthClaims>,
}
#[derive(Deserialize)]
struct AccountUserAuthClaims {
chatgpt_account_user_id: Option<String>,
chatgpt_account_id: Option<String>,
}
#[derive(Debug, Error)]
pub enum IdTokenInfoError {
#[error("invalid ID token format")]
@@ -134,6 +146,31 @@ pub fn parse_jwt_expiration(jwt: &str) -> Result<Option<DateTime<Utc>>, IdTokenI
.and_then(|exp| DateTime::<Utc>::from_timestamp(exp, 0)))
}
// Keep membership parsing separate from ordinary auth claims: a malformed optional
// membership must disable local verification without breaking an otherwise valid login.
// Both paths share decode_jwt_payload for the JWT envelope.
pub(crate) fn parse_chatgpt_account_user_id(
jwt: &str,
account_id: &str,
) -> Result<Option<String>, IdTokenInfoError> {
if account_id.is_empty() || account_id.trim() != account_id {
return Ok(None);
}
if jwt.split('.').count() != 3 {
return Err(IdTokenInfoError::InvalidFormat);
}
let claims: AccountUserClaims = decode_jwt_payload(jwt)?;
let Some(auth) = claims.auth else {
return Ok(None);
};
if auth.chatgpt_account_id.as_deref() != Some(account_id) {
return Ok(None);
}
Ok(auth
.chatgpt_account_user_id
.filter(|id| !id.is_empty() && id.trim() == id.as_str()))
}
pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoError> {
let claims: IdClaims = decode_jwt_payload(jwt)?;
let email = claims