From 30f528f08458d46627897c278f73f87f2d6185c0 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 6 Aug 2026 11:14:18 +0800 Subject: [PATCH] fix(openai): simplify import and oauth validation Co-authored-by: Codex --- .../providers/openai/src/credential/admin.rs | 259 ++++++++++++++---- .../providers/openai/src/credential/oauth.rs | 73 +++-- .../openai/src/credential/token_client.rs | 5 +- .../providers/openai/src/credential/types.rs | 2 +- .../openai/tests/credential/admin.rs | 205 +++++++++----- .../openai/tests/credential/oauth.rs | 249 +++++------------ docs/api.md | 12 +- docs/architecture.md | 10 +- 8 files changed, 454 insertions(+), 361 deletions(-) diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index 03edfa3bf..3476915bc 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -8,6 +8,10 @@ use std::fmt; use std::sync::Arc; use std::time::SystemTime; +use base64::{ + Engine as _, + engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD}, +}; use chrono::{DateTime, FixedOffset, Utc}; use gateway_core::engine::credential::{ AccountAvailability, CredentialCasUpdate, CredentialRevision, LoadedCredential, @@ -25,7 +29,7 @@ use serde::Serialize; use serde_json::Value; use thiserror::Error; -use super::identity::{CodexAccountIdentityVerifier, CodexIdentityVerificationError}; +use super::identity::CodexAccountIdentityVerifier; use super::security::CodexCredentialCodec; use super::token_client::{RefreshFailure, TokenRefresher}; use super::types::{ @@ -38,6 +42,9 @@ const PROVIDER_NAME: &str = "openai"; const CODEX_CREDENTIAL_SCHEMA_VERSION: u32 = 1; const MAX_BATCH: usize = 200; const MAX_IMPORT_DOCUMENT_BYTES: usize = 64 * 1024 * 1024; +const MAX_IMPORT_IDENTITY_BYTES: usize = 2_048; +const MAX_IMPORT_EMAIL_BYTES: usize = 512; +const MAX_IMPORT_PLAN_BYTES: usize = 128; pub struct ImportCodexOAuthCredential { pub account_id: String, @@ -112,6 +119,15 @@ enum ParsedCodexAuthentication { }, } +#[derive(Default)] +struct UnverifiedOAuthClaims { + expires_at: Option>, + chatgpt_account_id: Option, + chatgpt_user_id: Option, + email: Option, + plan_type: Option, +} + impl fmt::Debug for ParsedCodexAuthentication { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -553,14 +569,6 @@ impl CodexCredentialAdmin { }) } - /// 完整 OAuth 授权按目标数据 ID 替换凭据与上游账号身份。 - pub(crate) fn prepare_reauthorization( - &self, - input: RotateManagedCodexCredential, - ) -> Result { - self.prepare_oauth_rotation(input, true) - } - fn prepare_oauth_rotation( &self, input: RotateManagedCodexCredential, @@ -642,7 +650,6 @@ impl CodexCredentialAdmin { /// 有状态的 Codex 手工刷新边界;消费调用方刚读取的当前 credential 并准备 CAS。 pub struct CodexCredentialAdminService { refresher: Arc, - verifier: Arc, leases: Arc, runtime_policy: Arc, } @@ -652,7 +659,6 @@ impl fmt::Debug for CodexCredentialAdminService { formatter .debug_struct("CodexCredentialAdminService") .field("refresher", &"TokenRefresher") - .field("verifier", &"CodexAccountIdentityVerifier") .field("leases", &"ProviderLeasePort") .field("runtime_policy", &"ProviderRuntimePolicyPort") .finish() @@ -662,13 +668,12 @@ impl fmt::Debug for CodexCredentialAdminService { impl CodexCredentialAdminService { pub fn new( refresher: Arc, - verifier: Arc, + _verifier: Arc, leases: Arc, runtime_policy: Arc, ) -> Self { Self { refresher, - verifier, leases, runtime_policy, } @@ -764,7 +769,7 @@ impl CodexCredentialAdminService { Ok(prepared) } - /// 从 OAuth token 对补全账号事实,并归一到唯一 `NewProviderAccount` 写入路径。 + /// 对导入 token 做轻量 payload 检查,并归一到唯一 `NewProviderAccount` 写入路径。 pub async fn prepare_import_document( &self, payload: Value, @@ -794,9 +799,12 @@ impl CodexCredentialAdminService { if !account_ids.insert(account_id.clone()) { return Err(CodexCredentialAdminError::InvalidInput); } - let (secret, verified_account) = self - .verify_import_tokens(access_token.clone(), refresh_token.clone()) + let (secret, access_token_expires_at) = self + .resolve_import_tokens(access_token.clone(), refresh_token.clone()) .await?; + let mut verified_account = + unverified_oauth_profile(&secret, &account_id, access_token_expires_at); + apply_import_metadata(&mut verified_account, &candidate); if !upstream_identities.insert(( verified_account.chatgpt_user_id.clone(), verified_account.chatgpt_account_id.clone(), @@ -823,9 +831,11 @@ impl CodexCredentialAdminService { )?; CodexCredentialAdmin.prepare_import(ImportCodexOAuthCredential { account_id, - name: verified_account - .email + name: candidate + .name .clone() + .or_else(|| candidate.email.clone()) + .or_else(|| verified_account.email.clone()) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| "Codex OAuth".to_owned()), secret, @@ -866,11 +876,11 @@ impl CodexCredentialAdminService { Ok(PreparedCodexAccountImport { accounts }) } - async fn verify_import_tokens( + async fn resolve_import_tokens( &self, access_token: Option, refresh_token: Option, - ) -> Result<(CodexOAuthSecret, CodexAccountProfile), CodexCredentialAdminError> { + ) -> Result<(CodexOAuthSecret, Option>), CodexCredentialAdminError> { let access_token = access_token .as_deref() .map(normalize_bearer) @@ -880,21 +890,13 @@ impl CodexCredentialAdminService { .map(normalize_bearer) .filter(|token| !token.is_empty()); if let Some(access_token) = access_token { + validate_unverified_import_access_token(&access_token)?; let secret = CodexOAuthSecret { - access_token: SecretString::from(access_token), - refresh_token: refresh_token.clone().map(SecretString::from), + access_token: SecretString::from(access_token.clone()), + refresh_token: refresh_token.map(SecretString::from), id_token: None, }; - match self.verifier.verify_import(&secret).await { - Ok(profile) => return Ok((secret, profile)), - Err(CodexIdentityVerificationError::Unavailable) => { - return Err(CodexCredentialAdminError::IdentityUnavailable); - } - Err(CodexIdentityVerificationError::Rejected) if refresh_token.is_none() => { - return Err(CodexCredentialAdminError::IdentityRejected); - } - Err(CodexIdentityVerificationError::Rejected) => {} - } + return Ok((secret, unverified_access_token_expiry(&access_token))); } let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?; let tokens = self @@ -905,6 +907,7 @@ impl CodexCredentialAdminService { if tokens.access_token.trim().is_empty() || tokens.expires_in.is_zero() { return Err(CodexCredentialAdminError::InvalidCredential); } + validate_unverified_import_access_token(&tokens.access_token)?; let secret = CodexOAuthSecret { access_token: SecretString::from(tokens.access_token), // RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。 @@ -914,12 +917,11 @@ impl CodexCredentialAdminService { .or_else(|| Some(SecretString::from(refresh_token))), id_token: None, }; - let profile = self - .verifier - .verify_import(&secret) - .await - .map_err(map_identity_error)?; - Ok((secret, profile)) + let access_token_expires_at = SystemTime::now() + .checked_add(tokens.expires_in) + .map(DateTime::::from) + .ok_or(CodexCredentialAdminError::InvalidCredential)?; + Ok((secret, Some(access_token_expires_at))) } fn prepare_agent_identity_import( @@ -1046,15 +1048,6 @@ const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError } } -const fn map_identity_error(error: CodexIdentityVerificationError) -> CodexCredentialAdminError { - match error { - CodexIdentityVerificationError::Rejected => CodexCredentialAdminError::IdentityRejected, - CodexIdentityVerificationError::Unavailable => { - CodexCredentialAdminError::IdentityUnavailable - } - } -} - fn parse_import_document( payload: &Value, ) -> Result, CodexCredentialAdminError> { @@ -1067,20 +1060,56 @@ fn parse_import_document( if !is_openai_oauth_candidate(value) { continue; } - accounts.push(ParsedCodexImportAccount { - id: None, - name: None, - email: None, - plan_type: None, - chatgpt_account_id: None, - chatgpt_user_id: None, - authentication: parse_oauth_import_tokens(value)?, - status: None, - }); + accounts.push(parse_oauth_import_account(value)?); } Ok(accounts) } +fn parse_oauth_import_account( + value: &Value, +) -> Result { + let credentials = value.get("credentials").unwrap_or(value); + Ok(ParsedCodexImportAccount { + id: first_string(value, &["id"]), + name: first_string(value, &["name", "label"]), + email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])), + plan_type: first_string(value, &["plan_type", "planType"]) + .or_else(|| first_string(credentials, &["plan_type", "planType"])), + chatgpt_account_id: first_string( + value, + &[ + "chatgpt_account_id", + "chatgptAccountId", + "account_id", + "accountId", + ], + ) + .or_else(|| { + first_string( + credentials, + &[ + "chatgpt_account_id", + "chatgptAccountId", + "account_id", + "accountId", + ], + ) + }), + chatgpt_user_id: first_string( + value, + &["chatgpt_user_id", "chatgptUserId", "user_id", "userId"], + ) + .or_else(|| { + first_string( + credentials, + &["chatgpt_user_id", "chatgptUserId", "user_id", "userId"], + ) + }), + authentication: parse_oauth_import_tokens(value)?, + status: first_string(value, &["status"]), + }) +} + fn parse_agent_identity_account( value: &Value, ) -> Result { @@ -1242,6 +1271,122 @@ fn normalize_bearer(value: &str) -> String { .to_owned() } +pub(crate) fn unverified_oauth_profile( + secret: &CodexOAuthSecret, + fallback_account_id: &str, + access_token_expires_at: Option>, +) -> CodexAccountProfile { + let claims = + unverified_access_token_claims(secret.access_token.expose_secret()).unwrap_or_default(); + let fallback_identity = imported_identity(fallback_account_id); + let chatgpt_account_id = claims + .chatgpt_account_id + .as_deref() + .and_then(normalize_import_identity) + .unwrap_or_else(|| fallback_identity.clone()); + let chatgpt_user_id = claims + .chatgpt_user_id + .as_deref() + .and_then(normalize_import_identity) + .unwrap_or_else(|| chatgpt_account_id.clone()); + CodexAccountProfile { + email: claims + .email + .as_deref() + .and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)), + oauth_subject: chatgpt_user_id.clone(), + poid: None, + chatgpt_account_id, + chatgpt_user_id, + plan_type: claims + .plan_type + .as_deref() + .and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)), + access_token_expires_at: access_token_expires_at.or(claims.expires_at), + } +} + +fn apply_import_metadata(profile: &mut CodexAccountProfile, candidate: &ParsedCodexImportAccount) { + if profile.email.is_none() { + profile.email = candidate + .email + .as_deref() + .and_then(|value| normalize_import_text(value, MAX_IMPORT_EMAIL_BYTES)); + } + if profile.plan_type.is_none() { + profile.plan_type = candidate + .plan_type + .as_deref() + .and_then(|value| normalize_import_text(value, MAX_IMPORT_PLAN_BYTES)); + } +} + +fn validate_unverified_import_access_token(token: &str) -> Result<(), CodexCredentialAdminError> { + let claims = unverified_access_token_claims(token) + .ok_or(CodexCredentialAdminError::InvalidCredential)?; + if claims + .expires_at + .is_none_or(|expires_at| expires_at <= Utc::now()) + || claims + .chatgpt_account_id + .as_deref() + .is_none_or(|account_id| account_id.trim().is_empty()) + { + return Err(CodexCredentialAdminError::InvalidCredential); + } + Ok(()) +} + +fn unverified_access_token_expiry(token: &str) -> Option> { + unverified_access_token_claims(token).and_then(|claims| claims.expires_at) +} + +fn unverified_access_token_claims(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| URL_SAFE.decode(payload)) + .ok()?; + let claims = serde_json::from_slice::(&decoded).ok()?; + let claims = claims.as_object()?; + let auth = claims + .get("https://api.openai.com/auth") + .and_then(Value::as_object); + let profile = claims + .get("https://api.openai.com/profile") + .and_then(Value::as_object); + let text = |object: Option<&serde_json::Map>, key| { + object + .and_then(|object| object.get(key)) + .and_then(Value::as_str) + .map(str::to_owned) + }; + Some(UnverifiedOAuthClaims { + expires_at: claims + .get("exp") + .and_then(Value::as_i64) + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)), + chatgpt_account_id: text(auth, "chatgpt_account_id"), + chatgpt_user_id: text(auth, "chatgpt_user_id").or_else(|| text(profile, "chatgpt_user_id")), + email: text(profile, "email"), + plan_type: text(auth, "chatgpt_plan_type").or_else(|| text(profile, "chatgpt_plan_type")), + }) +} + +fn imported_identity(account_id: &str) -> String { + format!("imported_{}", account_id.trim_start_matches("acct_")) +} + +fn normalize_import_identity(value: &str) -> Option { + normalize_import_text(value, MAX_IMPORT_IDENTITY_BYTES) +} + +fn normalize_import_text(value: &str, max_bytes: usize) -> Option { + let value = value.trim(); + (!value.is_empty() && value.len() <= max_bytes && !value.chars().any(char::is_control)) + .then(|| value.to_owned()) +} + fn import_runtime_state( status: Option<&str>, ) -> Result<(bool, AccountAvailability), CodexCredentialAdminError> { diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index eca255ecf..2ae67ae94 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -1,6 +1,10 @@ //! Codex Authorization Code + PKCE/OIDC 管理流。 -use std::{fmt, sync::Arc, time::Duration}; +use std::{ + fmt, + sync::Arc, + time::{Duration, SystemTime}, +}; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -24,11 +28,9 @@ use uuid::Uuid; use super::admin::{ CodexCredentialAdmin, CodexCredentialAdminError, ImportCodexOAuthCredential, - PreparedCodexCredentialRotation, RotateManagedCodexCredential, refresh_time, -}; -use super::identity::{ - CodexAccountIdentityVerifier, CodexIdentityExpectation, CodexIdentityVerificationError, + PreparedCodexCredentialRotation, refresh_time, unverified_oauth_profile, }; +use super::identity::CodexAccountIdentityVerifier; use super::token_client::{ AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant, OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI, @@ -344,7 +346,6 @@ pub trait CodexOAuthAdmin: Send + Sync { pub struct CodexOAuthAdminService { pending: Arc, exchanger: Arc, - verifier: Arc, store: Arc, runtime_policy: Arc, credentials: CodexCredentialAdmin, @@ -396,7 +397,7 @@ impl CodexOAuthAdminService { pub fn new( pending: Arc, exchanger: Arc, - verifier: Arc, + _verifier: Arc, store: Arc, runtime_policy: Arc, credentials: CodexCredentialAdmin, @@ -404,7 +405,6 @@ impl CodexOAuthAdminService { Self { pending, exchanger, - verifier, store, runtime_policy, credentials, @@ -487,7 +487,8 @@ impl CodexOAuthAdminService { callback_url: &str, ) -> Result<(PendingAuthorizationMutation, CompletedCodexOAuthCredential), CodexOAuthAdminError> { - let (mut secret, id_token) = self.exchange_pending(&pending, callback_url).await?; + let (mut secret, id_token, expires_in) = + self.exchange_pending(&pending, callback_url).await?; let mutation = pending.mutation.clone(); let current = if let Some(target) = pending.reauthorization() { let current = self @@ -499,13 +500,10 @@ impl CodexOAuthAdminService { } else { None }; - let expectation = CodexIdentityExpectation::default(); - let profile = self - .verifier - .verify_authorization(&secret, &id_token, &pending.nonce, &expectation) - .await - .and_then(super::identity::CodexIdentityVerification::into_complete) - .map_err(map_identity_error)?; + let access_token_expires_at = SystemTime::now() + .checked_add(expires_in) + .map(DateTime::::from) + .ok_or(CodexOAuthAdminError::Credential)?; secret.id_token = Some(id_token); let policy = self .runtime_policy @@ -516,28 +514,33 @@ impl CodexOAuthAdminService { let next_refresh_at = refresh_time( policy, current.account.id(), - profile.access_token_expires_at, + Some(access_token_expires_at), secret.refresh_token.is_some(), ) .map_err(map_admin_error)?; CompletedCodexOAuthCredential::Reauthorize( self.credentials - .prepare_reauthorization(RotateManagedCodexCredential { + .prepare_refreshed_oauth_rotation( current, secret, - verified_account: profile, + access_token_expires_at, next_refresh_at, - }) + ) .map_err(map_admin_error)?, ) } else { let account_id = format!("acct_{}", Uuid::now_v7().simple()); let typed_account_id = ProviderAccountId::new(account_id.clone()) .map_err(|_| CodexOAuthAdminError::Credential)?; + let profile = unverified_oauth_profile( + &secret, + typed_account_id.as_str(), + Some(access_token_expires_at), + ); let next_refresh_at = refresh_time( policy, &typed_account_id, - profile.access_token_expires_at, + Some(access_token_expires_at), secret.refresh_token.is_some(), ) .map_err(map_admin_error)?; @@ -654,7 +657,8 @@ impl CodexOAuthAdminService { &self, pending: &CodexPendingAuthorization, callback_url: &str, - ) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> { + ) -> Result<(super::types::CodexOAuthSecret, SecretString, Duration), CodexOAuthAdminError> + { let (code, callback_state) = callback_parts(callback_url)?; if !constant_time_equal( pending.state.expose_secret().as_bytes(), @@ -670,7 +674,7 @@ impl CodexOAuthAdminService { }) .await .map_err(map_exchange_error)?; - Ok((tokens.secret, tokens.id_token)) + Ok((tokens.secret, tokens.id_token, tokens.expires_in)) } } @@ -699,27 +703,19 @@ fn authorization_url( fn callback_parts(value: &str) -> Result<(SecretString, SecretString), CodexOAuthAdminError> { let url = Url::parse(value).map_err(|_| CodexOAuthAdminError::CallbackRejected)?; - if url.scheme() != "http" - || url.host_str() != Some("localhost") - || url.port() != Some(1455) - || url.path() != "/auth/callback" - || url.fragment().is_some() - { - return Err(CodexOAuthAdminError::CallbackRejected); - } let mut code = None; let mut state = None; for (key, value) in url.query_pairs() { match key.as_ref() { "code" => set_unique_callback_parameter(&mut code, value.into_owned())?, "state" => set_unique_callback_parameter(&mut state, value.into_owned())?, - // OAuth 服务可能附加 `scope`、`iss` 等标准参数。 - // 固定回调地址与唯一的 `code`/`state` 才是安全边界,无关参数不参与校验。 + // 回调地址只承载 query 参数;安全绑定由唯一的 code/state、 + // 服务端保存的 state 和 PKCE token exchange 共同完成。 _ => {} } } - let code = code.filter(|value| valid_secret(value)); - let state = state.filter(|value| valid_secret(value)); + let code = code.filter(|value| !value.is_empty()); + let state = state.filter(|value| !value.is_empty()); match (code, state) { (Some(code), Some(state)) => Ok((SecretString::from(code), SecretString::from(state))), _ => Err(CodexOAuthAdminError::CallbackRejected), @@ -813,13 +809,6 @@ fn map_exchange_error(error: AuthorizationCodeExchangeError) -> CodexOAuthAdminE } } -fn map_identity_error(error: CodexIdentityVerificationError) -> CodexOAuthAdminError { - match error { - CodexIdentityVerificationError::Rejected => CodexOAuthAdminError::IdentityRejected, - CodexIdentityVerificationError::Unavailable => CodexOAuthAdminError::UpstreamUnavailable, - } -} - fn map_admin_error(_: CodexCredentialAdminError) -> CodexOAuthAdminError { CodexOAuthAdminError::Credential } diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index f962429f2..91cbcb424 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -76,10 +76,11 @@ impl fmt::Debug for AuthorizationCodeGrant { } } -/// 官方 token endpoint 返回、尚待 OIDC/JWKS 绑定校验的 token set。 +/// 官方 token endpoint 完成协议校验后返回的 token set。 pub struct AuthorizationTokenSet { pub secret: crate::credential::CodexOAuthSecret, pub id_token: SecretString, + pub expires_in: Duration, } impl fmt::Debug for AuthorizationTokenSet { @@ -88,6 +89,7 @@ impl fmt::Debug for AuthorizationTokenSet { .debug_struct("AuthorizationTokenSet") .field("secret", &"[REDACTED]") .field("id_token", &"[REDACTED]") + .field("expires_in", &self.expires_in) .finish() } } @@ -267,6 +269,7 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient { id_token: None, }, id_token: SecretString::from(tokens.id_token), + expires_in: Duration::from_secs(tokens.expires_in), }) } } diff --git a/backend/crates/providers/openai/src/credential/types.rs b/backend/crates/providers/openai/src/credential/types.rs index b83f73a94..2ea8b4e41 100644 --- a/backend/crates/providers/openai/src/credential/types.rs +++ b/backend/crates/providers/openai/src/credential/types.rs @@ -29,7 +29,7 @@ impl fmt::Debug for CodexOAuthSecret { } } -/// 已由官方 JWT/OIDC 与认证 usage 响应共同确认的完整账号投影。 +/// OAuth credential 持久化时使用的账号投影。 #[derive(Clone)] pub struct CodexAccountProfile { pub email: Option, diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index d3a1409bc..6d3af378f 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -4,7 +4,7 @@ use std::time::Duration; use async_trait::async_trait; use base64::Engine as _; -use base64::engine::general_purpose::STANDARD; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use chrono::{TimeZone, Utc}; use ed25519_dalek::SigningKey; use ed25519_dalek::pkcs8::EncodePrivateKey as _; @@ -46,6 +46,22 @@ fn encoded_credential(account_id: &str, token: &str) -> PlaintextCredential { .expect("encode current credential") } +fn unverified_import_access_token(account_id: &str, user_id: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ + "sub": format!("subject-{user_id}"), + "exp": Utc::now().timestamp() + 3_600, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_user_id": user_id, + }, + })) + .expect("serialize unsigned import JWT"), + ); + format!("{header}.{payload}.") +} + #[test] fn prepare_import_returns_core_account_with_plaintext_json() { let prepared = CodexCredentialAdmin @@ -315,11 +331,10 @@ fn cpr_export_preserves_agent_identity_material_without_oauth_fields() { #[tokio::test] async fn cpr_export_and_import_support_mixed_oauth_and_agent_identity_accounts() { let (agent, _) = agent_export_item("acct_mixed_agent"); + let access_token = + unverified_import_access_token("chatgpt-acct_mixed_oauth", "user-acct_mixed_oauth"); let value = CodexCredentialAdmin - .format_cpr_export(vec![ - export_item("acct_mixed_oauth", "token-acct_mixed_oauth"), - agent, - ]) + .format_cpr_export(vec![export_item("acct_mixed_oauth", &access_token), agent]) .expect("mixed export") .into_json() .expect("serialize mixed export"); @@ -564,35 +579,16 @@ async fn manual_refresh_missing_lease_fails_before_exchange() { ); } -struct ImportVerifier; +struct RejectingImportVerifier; #[async_trait] -impl CodexAccountIdentityVerifier for ImportVerifier { +impl CodexAccountIdentityVerifier for RejectingImportVerifier { async fn verify( &self, - secret: &provider_openai::credential::CodexOAuthSecret, - expectation: &CodexIdentityExpectation, + _secret: &provider_openai::credential::CodexOAuthSecret, + _expectation: &CodexIdentityExpectation, ) -> Result { - let suffix = secret - .access_token - .expose_secret() - .strip_prefix("token-") - .ok_or(CodexIdentityVerificationError::Rejected)?; - let mut profile = profile(&format!("chatgpt-{suffix}")); - if let Some(user) = suffix.strip_prefix("shared-") { - profile.chatgpt_account_id = "chatgpt-shared".to_owned(); - profile.chatgpt_user_id = format!("user-{user}"); - } - if expectation - .chatgpt_account_id() - .is_some_and(|expected| expected != profile.chatgpt_account_id) - || expectation - .chatgpt_user_id() - .is_some_and(|expected| expected != profile.chatgpt_user_id) - { - return Err(CodexIdentityVerificationError::Rejected); - } - Ok(CodexIdentityVerification::Complete(profile)) + Err(CodexIdentityVerificationError::Rejected) } async fn verify_authorization( @@ -611,7 +607,7 @@ pub(super) fn import_service( ) -> provider_openai::credential::CodexCredentialAdminService { CodexCredentialAdminService::new( refresher, - Arc::new(ImportVerifier), + Arc::new(RejectingImportVerifier), Arc::new(ManualLeases { requests: Mutex::new(Vec::new()), drops: Arc::new(AtomicUsize::new(0)), @@ -629,19 +625,20 @@ pub(super) fn unused_import_refresher() -> Arc { } #[tokio::test] -async fn oauth_import_uses_only_tokens_and_verified_identity() { +async fn oauth_import_uses_token_identity_and_source_display_metadata() { let refresher = unused_import_refresher(); + let access_token = unverified_import_access_token("chatgpt-cpr", "user-token-cpr"); let prepared = import_service(refresher.clone()) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", "accounts": [{ "id": "acct_cpr_import", "email": "cpr@example.com", - "accountId": "chatgpt-cpr", - "userId": "user-chatgpt-cpr", + "accountId": "source-account-cpr", + "userId": "source-user-cpr", "label": "CPR account", "planType": "pro", - "token": "Bearer token-cpr", + "token": format!("Bearer {access_token}"), "refreshToken": "refresh-cpr", "accessTokenExpiresAt": "2100-01-01T00:00:00+00:00", "status": "disabled", @@ -657,6 +654,10 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() { assert!(account.account.id().as_str().starts_with("acct_")); assert!(account.account.enabled()); assert_eq!(account.account.upstream_account_id(), Some("chatgpt-cpr")); + assert_eq!(account.account.upstream_user_id(), "user-token-cpr"); + assert_eq!(account.account.name(), "CPR account"); + assert_eq!(account.account.email(), Some("cpr@example.com")); + assert_eq!(account.account.plan_type(), Some("pro")); let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential"); assert_eq!( runtime @@ -665,13 +666,64 @@ async fn oauth_import_uses_only_tokens_and_verified_identity() { .expect("OAuth credential") .access_token .expose_secret(), - "token-cpr" + access_token ); assert!(refresher.seen.lock().expect("seen tokens").is_empty()); } +#[tokio::test] +async fn refresh_token_only_import_persists_exchanged_tokens_without_identity_verification() { + let refreshed_access = + unverified_import_access_token("chatgpt-refreshed-import", "user-refreshed-import"); + let expected_access = refreshed_access.clone(); + let refresher = Arc::new(ManualRefresher { + outcome: Mutex::new(Some(Ok(TokenPair { + access_token: refreshed_access, + refresh_token: Some("rotated-import-refresh".to_owned()), + expires_in: Duration::from_secs(3_600), + }))), + seen: Mutex::new(Vec::new()), + }); + + let prepared = import_service(Arc::clone(&refresher)) + .prepare_import_document(serde_json::json!({ + "refreshToken": "refresh-only" + })) + .await + .expect("refresh-token-only import"); + + assert_eq!(prepared.accounts().len(), 1); + let account = &prepared.accounts()[0]; + assert_eq!( + account.account.upstream_account_id(), + Some("chatgpt-refreshed-import") + ); + assert_eq!(account.account.upstream_user_id(), "user-refreshed-import"); + let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential"); + let oauth = runtime.authentication.oauth().expect("OAuth credential"); + assert_eq!(oauth.access_token.expose_secret(), expected_access); + assert_eq!( + oauth + .refresh_token + .as_ref() + .expect("rotated RT") + .expose_secret(), + "rotated-import-refresh" + ); + assert_eq!( + refresher + .seen + .lock() + .expect("seen refresh tokens") + .as_slice(), + ["refresh-only"] + ); +} + #[tokio::test] async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { + let shared_a = unverified_import_access_token("chatgpt-shared", "user-a"); + let shared_b = unverified_import_access_token("chatgpt-shared", "user-b"); let prepared = import_service(unused_import_refresher()) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", @@ -679,12 +731,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { "id": "acct_shared_a", "accountId": "chatgpt-shared", "userId": "user-a", - "token": "token-shared-a" + "token": shared_a }, { "id": "acct_shared_b", "accountId": "chatgpt-shared", "userId": "user-b", - "token": "token-shared-b" + "token": shared_b }] })) .await @@ -698,6 +750,7 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { .all(|account| { account.account.upstream_account_id() == Some("chatgpt-shared") }) ); + let duplicate_token = unverified_import_access_token("chatgpt-shared", "user-a"); let duplicate = import_service(unused_import_refresher()) .prepare_import_document(serde_json::json!({ "sourceFormat": "cpr", @@ -705,12 +758,12 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { "id": "acct_duplicate_a", "accountId": "chatgpt-shared", "userId": "user-a", - "token": "token-shared-a" + "token": duplicate_token.clone() }, { "id": "acct_duplicate_b", "accountId": "chatgpt-shared", "userId": "user-a", - "token": "token-shared-a" + "token": duplicate_token }] })) .await @@ -719,7 +772,8 @@ async fn cpr_batch_allows_distinct_users_in_the_same_workspace() { } #[tokio::test] -async fn oauth_import_extracts_tokens_without_parsing_source_projections() { +async fn oauth_import_extracts_tokens_and_preserves_source_display_metadata() { + let bundle_token = unverified_import_access_token("chatgpt-token-bundle", "user-token-bundle"); let bundle = import_service(unused_import_refresher()) .prepare_import_document(serde_json::json!({ "exported_at": "2026-07-03T15:46:38.717Z", @@ -729,10 +783,12 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() { "platform": "openai", "type": "oauth", "credentials": { - "access_token": "token-bundle", + "access_token": bundle_token, "refresh_token": "refresh-bundle", - "chatgpt_account_id": "chatgpt-bundle", - "chatgpt_user_id": "user-chatgpt-bundle" + "chatgpt_account_id": "source-bundle", + "chatgpt_user_id": "source-user-bundle", + "email": "bundle-source@example.com", + "plan_type": "team" }, "concurrency": 3, "priority": 50 @@ -740,48 +796,62 @@ async fn oauth_import_extracts_tokens_without_parsing_source_projections() { })) .await .expect("credential bundle import"); + let auth_document_token = + unverified_import_access_token("chatgpt-token-auth-document", "user-token-auth-document"); let auth_document = import_service(unused_import_refresher()) .prepare_import_document(serde_json::json!({ "accounts": [{ "type": "openai", - "access_token": "token-auth-document", + "access_token": auth_document_token, "refresh_token": "refresh-auth-document", - "chatgpt_account_id": "chatgpt-auth-document", - "chatgpt_user_id": "user-chatgpt-auth-document", + "chatgpt_account_id": "source-auth-document", + "chatgpt_user_id": "source-user-auth-document", "email": "auth-document@example.com", - "label": "Auth document" + "label": "Auth document", + "planType": "plus" }] })) .await .expect("auth document import"); - for (prepared, expected) in [ - (&bundle, "chatgpt-bundle"), - (&auth_document, "chatgpt-auth-document"), - ] { - assert_eq!(prepared.accounts().len(), 1); - assert_eq!( - prepared.accounts()[0].account.upstream_account_id(), - Some(expected) - ); - assert!( - prepared.accounts()[0] - .account - .id() - .as_str() - .starts_with("acct_") - ); - } + let bundle_account = &bundle.accounts()[0].account; + assert_eq!(bundle.accounts().len(), 1); + assert_eq!( + bundle_account.upstream_account_id(), + Some("chatgpt-token-bundle") + ); + assert_eq!(bundle_account.upstream_user_id(), "user-token-bundle"); + assert_eq!(bundle_account.name(), "bundle@example.com"); + assert_eq!(bundle_account.email(), Some("bundle-source@example.com")); + assert_eq!(bundle_account.plan_type(), Some("team")); + + let auth_document_account = &auth_document.accounts()[0].account; + assert_eq!(auth_document.accounts().len(), 1); + assert_eq!( + auth_document_account.upstream_account_id(), + Some("chatgpt-token-auth-document") + ); + assert_eq!( + auth_document_account.upstream_user_id(), + "user-token-auth-document" + ); + assert_eq!(auth_document_account.name(), "Auth document"); + assert_eq!( + auth_document_account.email(), + Some("auth-document@example.com") + ); + assert_eq!(auth_document_account.plan_type(), Some("plus")); assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-bundle")); assert!(!format!("{bundle:?} {auth_document:?}").contains("refresh-auth-document")); } #[tokio::test] async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() { + let access_token = unverified_import_access_token("chatgpt-token-cpa", "user-token-cpa"); let prepared = import_service(unused_import_refresher()) .prepare_import_document(serde_json::json!({ "type": "codex", - "access_token": "token-cpa", + "access_token": access_token, "refresh_token": "refresh-cpa", "id_token": "id-cpa", "account_id": "chatgpt-cpa", @@ -793,7 +863,8 @@ async fn cliproxyapi_codex_auth_file_is_recognized_as_an_openai_auth_document() assert_eq!(prepared.accounts().len(), 1); let account = &prepared.accounts()[0].account; - assert_eq!(account.upstream_account_id(), Some("chatgpt-cpa")); - assert_eq!(account.email(), Some("chatgpt-cpa@example.com")); + assert_eq!(account.upstream_account_id(), Some("chatgpt-token-cpa")); + assert_eq!(account.upstream_user_id(), "user-token-cpa"); + assert_eq!(account.email(), Some("cpa@example.com")); assert_eq!(account.authentication_kind(), "oauth"); } diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index 2bf278249..76a47b403 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -1,4 +1,10 @@ -use std::sync::{Arc, Mutex}; +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; @@ -158,19 +164,30 @@ impl AuthorizationCodeExchanger for Exchanger { id_token: None, }, id_token: SecretString::from("oauth-id-token"), + expires_in: Duration::from_secs(3_600), }) } } -struct Verifier; +#[derive(Default)] +struct RejectingVerifier { + calls: AtomicUsize, +} + +impl RejectingVerifier { + fn call_count(&self) -> usize { + self.calls.load(Ordering::Relaxed) + } +} #[async_trait] -impl CodexAccountIdentityVerifier for Verifier { +impl CodexAccountIdentityVerifier for RejectingVerifier { async fn verify( &self, _secret: &CodexOAuthSecret, _expectation: &CodexIdentityExpectation, ) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); Err(CodexIdentityVerificationError::Rejected) } @@ -181,18 +198,17 @@ impl CodexAccountIdentityVerifier for Verifier { expected_nonce: &SecretString, _expectation: &CodexIdentityExpectation, ) -> Result { - if id_token.expose_secret() != "oauth-id-token" || expected_nonce.expose_secret().len() < 16 - { - return Err(CodexIdentityVerificationError::Rejected); - } - Ok(CodexIdentityVerification::Complete(profile( - "chatgpt-oauth", - ))) + let _ = (id_token, expected_nonce); + self.calls.fetch_add(1, Ordering::Relaxed); + Err(CodexIdentityVerificationError::Rejected) } } fn service() -> CodexOAuthAdminService { - service_with_store(Arc::new(MemoryAccountStore::default()), Arc::new(Verifier)) + service_with_store( + Arc::new(MemoryAccountStore::default()), + Arc::new(RejectingVerifier::default()), + ) } fn service_with_store( @@ -283,8 +299,10 @@ async fn start_authorization_uses_pkce_nonce_and_fixed_official_redirect() { } #[tokio::test] -async fn completion_returns_verified_core_account_without_writing_store() { - let service = service(); +async fn completion_prepares_core_account_without_identity_verification() { + let store = Arc::new(MemoryAccountStore::default()); + let verifier = Arc::new(RejectingVerifier::default()); + let service = service_with_store(store, verifier.clone()); let started = started(&service).await; let url = Url::parse(&started.authorization_url).expect("authorization URL"); let state = url @@ -322,10 +340,11 @@ async fn completion_returns_verified_core_account_without_writing_store() { .and_then(serde_json::Value::as_str), Some("oauth-id-token") ); + assert_eq!(verifier.call_count(), 0); } #[tokio::test] -async fn completion_accepts_unrelated_oauth_callback_parameters() { +async fn completion_accepts_parseable_non_localhost_callback_url() { let service = service(); let started = started(&service).await; let state = Url::parse(&started.authorization_url) @@ -339,11 +358,11 @@ async fn completion_accepts_unrelated_oauth_callback_parameters() { owner_ref: owner_ref(), flow_id: started.flow_id, callback_url: SecretString::from(format!( - "http://localhost:1455/auth/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com" + "https://relay-next.aivify.cc/relay/openai/callback?code=authorization-code&state={state}&scope=openid%20profile&iss=https%3A%2F%2Fauth.openai.com" )), }) .await - .expect("complete OAuth with standard callback parameters"); + .expect("complete OAuth from a relayed callback URL"); assert!(matches!( prepared.credential, @@ -399,7 +418,16 @@ async fn completion_rejects_repeated_state_callback_parameter() { assert_eq!(error, CodexOAuthAdminError::CallbackRejected); } -struct UnavailableVerifier; +#[derive(Default)] +struct UnavailableVerifier { + calls: AtomicUsize, +} + +impl UnavailableVerifier { + fn call_count(&self) -> usize { + self.calls.load(Ordering::Relaxed) + } +} #[async_trait] impl CodexAccountIdentityVerifier for UnavailableVerifier { @@ -408,6 +436,7 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier { _: &CodexOAuthSecret, _: &CodexIdentityExpectation, ) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); Err(CodexIdentityVerificationError::Unavailable) } @@ -418,14 +447,16 @@ impl CodexAccountIdentityVerifier for UnavailableVerifier { _: &SecretString, _: &CodexIdentityExpectation, ) -> Result { + self.calls.fetch_add(1, Ordering::Relaxed); Err(CodexIdentityVerificationError::Unavailable) } } #[tokio::test] -async fn completion_classifies_unavailable_identity_verification_as_upstream_unavailable() { +async fn completion_ignores_unavailable_identity_verifier() { let store = Arc::new(MemoryAccountStore::default()); - let service = service_with_store(store, Arc::new(UnavailableVerifier)); + let verifier = Arc::new(UnavailableVerifier::default()); + let service = service_with_store(store, verifier.clone()); let started = started(&service).await; let state = Url::parse(&started.authorization_url) .expect("authorization URL") @@ -433,7 +464,7 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) .expect("state"); - let error = service + let prepared = service .complete_authorization(CompleteCodexOAuthAuthorization { owner_ref: owner_ref(), flow_id: started.flow_id, @@ -442,9 +473,13 @@ async fn completion_classifies_unavailable_identity_verification_as_upstream_una )), }) .await - .expect_err("identity verification unavailable"); + .expect("complete OAuth without identity verification"); - assert_eq!(error, CodexOAuthAdminError::UpstreamUnavailable); + assert!(matches!( + prepared.credential, + CompletedCodexOAuthCredential::Create(_) + )); + assert_eq!(verifier.call_count(), 0); } #[tokio::test] @@ -523,7 +558,7 @@ fn oauth_command_debug_redacts_owner_flow_and_callback() { } #[tokio::test] -async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotation() { +async fn reauthorization_preserves_existing_principal_without_identity_verification() { let store = Arc::new(MemoryAccountStore::default()); store .seed_oauth_credential(ImportCodexOAuthCredential { @@ -538,13 +573,15 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio let original_account = store .account("acct_oauth_reauth") .expect("original account"); - let original_installation_id = store + let original_runtime = store .repository() .load_runtime_credential(&original_account) .await - .expect("original credential") - .installation_id; - let service = service_with_store(store.clone(), Arc::new(Verifier)); + .expect("original credential"); + let original_installation_id = original_runtime.installation_id; + let original_principal = original_runtime.principal; + let verifier = Arc::new(RejectingVerifier::default()); + let service = service_with_store(store.clone(), verifier.clone()); let started = service .start_authorization(StartCodexOAuthAuthorization { mutation: reauthorization_mutation("request-reauth", "acct_oauth_reauth"), @@ -590,6 +627,9 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio "oauth-access-token" ); assert_eq!(runtime.installation_id, original_installation_id); + assert_eq!(runtime.principal, original_principal); + assert!(prepared.replacement_identity.is_none()); + assert_eq!(verifier.call_count(), 0); assert_eq!( store .account("acct_oauth_reauth") @@ -599,158 +639,3 @@ async fn reauthorization_uses_revision_advanced_after_start_for_prepared_rotatio 2 ); } - -struct ReplacementIdentityVerifier; - -#[async_trait] -impl CodexAccountIdentityVerifier for ReplacementIdentityVerifier { - async fn verify( - &self, - _: &CodexOAuthSecret, - _: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) - } - - async fn verify_authorization( - &self, - _: &CodexOAuthSecret, - _: &SecretString, - _: &SecretString, - expectation: &CodexIdentityExpectation, - ) -> Result { - assert_eq!(expectation, &CodexIdentityExpectation::default()); - Ok(CodexIdentityVerification::Complete(profile( - "chatgpt-different-owner", - ))) - } -} - -struct RotatedPrincipalVerifier; - -#[async_trait] -impl CodexAccountIdentityVerifier for RotatedPrincipalVerifier { - async fn verify( - &self, - _: &CodexOAuthSecret, - _: &CodexIdentityExpectation, - ) -> Result { - Err(CodexIdentityVerificationError::Rejected) - } - - async fn verify_authorization( - &self, - _: &CodexOAuthSecret, - _: &SecretString, - _: &SecretString, - _: &CodexIdentityExpectation, - ) -> Result { - let mut verified = profile("chatgpt-oauth"); - verified.oauth_subject = "rotated-oauth-subject".to_owned(); - verified.poid = None; - Ok(CodexIdentityVerification::Complete(verified)) - } -} - -#[tokio::test] -async fn reauthorization_updates_changed_free_principal_for_same_account() { - let store = Arc::new(MemoryAccountStore::default()); - store - .seed_oauth_credential(ImportCodexOAuthCredential { - account_id: "acct_oauth_principal".to_owned(), - name: "reauthorize".to_owned(), - secret: secret("old-oauth-access"), - verified_account: profile("chatgpt-oauth"), - next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)), - enabled: true, - }) - .await; - let service = service_with_store(store, Arc::new(RotatedPrincipalVerifier)); - let started = service - .start_authorization(StartCodexOAuthAuthorization { - mutation: reauthorization_mutation("request-rotated-principal", "acct_oauth_principal"), - }) - .await - .expect("start reauthorization"); - let state = Url::parse(&started.authorization_url) - .expect("authorization URL") - .query_pairs() - .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) - .expect("state"); - let prepared = service - .complete_authorization(CompleteCodexOAuthAuthorization { - owner_ref: owner_ref(), - flow_id: started.flow_id, - callback_url: SecretString::from(format!( - "http://localhost:1455/auth/callback?code=authorization-code&state={state}" - )), - }) - .await - .expect("complete reauthorization"); - let CompletedCodexOAuthCredential::Reauthorize(prepared) = prepared.credential else { - panic!("expected prepared reauthorization"); - }; - let runtime = - provider_openai::credential::CodexCredentialCodec::decode(prepared.credential.credential()) - .expect("prepared credential"); - let principal = runtime.principal.expect("OAuth principal"); - - assert_eq!(principal.oauth_subject, "rotated-oauth-subject"); - assert_eq!(principal.poid, None); -} - -#[tokio::test] -async fn reauthorization_rebinds_target_record_to_authorized_identity() { - let store = Arc::new(MemoryAccountStore::default()); - store - .seed_oauth_credential(ImportCodexOAuthCredential { - account_id: "acct_oauth_rebind".to_owned(), - name: "reauthorize".to_owned(), - secret: secret("old-oauth-access"), - verified_account: profile("chatgpt-oauth"), - next_refresh_at: Some(chrono::Utc::now() + chrono::Duration::minutes(30)), - enabled: true, - }) - .await; - let service = service_with_store(store, Arc::new(ReplacementIdentityVerifier)); - let started = service - .start_authorization(StartCodexOAuthAuthorization { - mutation: reauthorization_mutation("request-rebind", "acct_oauth_rebind"), - }) - .await - .expect("start reauthorization"); - let state = Url::parse(&started.authorization_url) - .expect("authorization URL") - .query_pairs() - .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) - .expect("state"); - let completed = service - .complete_authorization(CompleteCodexOAuthAuthorization { - owner_ref: owner_ref(), - flow_id: started.flow_id, - callback_url: SecretString::from(format!( - "http://localhost:1455/auth/callback?code=authorization-code&state={state}" - )), - }) - .await - .expect("complete identity replacement"); - let CompletedCodexOAuthCredential::Reauthorize(prepared) = completed.credential else { - panic!("expected prepared reauthorization"); - }; - assert_eq!( - prepared.credential.account_id().as_str(), - "acct_oauth_rebind" - ); - let replacement = prepared - .replacement_identity - .as_ref() - .expect("replacement identity"); - assert_eq!( - replacement.upstream_account_id(), - Some("chatgpt-different-owner") - ); - assert_eq!( - replacement.upstream_user_id(), - "user-chatgpt-different-owner" - ); -} diff --git a/docs/api.md b/docs/api.md index 0c4aa753d..ebfd985e6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -171,13 +171,13 @@ OAuth start 使用: ### OpenAI 身份、额度与状态 -- 导入会校验签名 access token 和稳定账号身份;`/wham/usage` 用于补全账号/套餐事实和额度, - 不是判断 token 是否有效的唯一依据。 +- 文件导入只轻量解析 access token 的未验签 JWT payload:要求 `exp` 未过期且存在 + `chatgpt_account_id`;不调用 JWKS 或 `/wham/usage`,也不做账号身份一致性比对。 +- 首次 OAuth 和重新授权保留回调 `state`、PKCE 与官方 token exchange。回调地址只承载 `code`/`state`, + 不以 host/path 形式作为拒绝条件。 - 账号文件导入和首次 OAuth 创建在 credential 提交后立即尝试一次额度观测。观测失败只记录告警, - 不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新。 -- 重新授权在 start 时绑定目标账号,complete 时重新读取目标的当前 credential revision,并验证上游 - account/user 身份没有换绑。Free token 缺少 `chatgpt_account_id` 时,后端使用目标账号 header 调用 - `/wham/usage` 交叉确认,而不是把该账号推断成 K12。 + 不回滚已提交的账号;重新授权只轮换目标账号 credential,不隐式等同于手工额度刷新,也不更新既有 + upstream account/user 或 OAuth principal。 - OAuth pending flow 先取得带过期时间的独占 claim,只有账号事务提交成功后才消费。失败会释放 claim, 但上游 authorization code 本身通常只能交换一次;已完成过 token exchange 时应重新创建 OAuth flow。 - `GET /accounts/quota` 只读取最后一次落库快照;`POST /accounts/quota/refresh` 才访问上游。access token diff --git a/docs/architecture.md b/docs/architecture.md index cb9c02b08..169ce53a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,11 +120,11 @@ credential 更新使用 `credential_revision` CAS。认证永久失败、封禁 `refresh_token_expires_at` 不是公共 SQL 列或 Core 权威状态。xAI 可在 `provider_credentials_json` 内保存它作为 Provider 私有提示;真正失效以 refresh endpoint 返回的永久错误为准。 -OpenAI 导入、首次 OAuth 和重新授权都先验证签名 token 与稳定账号身份。重新授权的目标绑定只保存 -目标账号 ID,不接受或冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 -revision,并以当前 revision 做最终 CAS。上游账号或用户身份发生换绑时拒绝提交;Free token 缺少 -账号 claim 时,用目标账号 header 下的 usage 身份做交叉确认。认证主体投影可以随同一上游账号的 -合法重新授权更新,但目标 upstream account/user 不得改变。 +OpenAI 导入只对 access token 做轻量、未验签的 JWT payload 检查:payload 可解析、`exp` 未过期且 +带有 `chatgpt_account_id`。首次 OAuth 和重新授权保留服务端 `state`、PKCE 与官方 token exchange; +它们不再调用 JWKS、usage 或账号身份一致性校验。重新授权的目标绑定只保存目标账号 ID,不接受或 +冻结客户端提供的 credential revision;complete 阶段重新读取目标账号及其当前 revision,并以当前 +revision 做最终 CAS。重新授权只轮换目标账号的 token,保留既有 upstream account/user 与 principal。 credential 与 quota 是两套状态机。主动 quota refresh 会拒绝过期 access token;quota 探测的 401/403 不具备判定 refresh token 永久失效的证据。成功主动观测和正常推理响应中的 rate-limit header