From b052e79a16828d46176d24da1fd1578849586808 Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 6 Aug 2026 15:49:42 +0800 Subject: [PATCH] fix(openai): preserve imported token expiry Co-authored-by: Codex --- .../providers/openai/src/credential/admin.rs | 28 +++++++++--- .../openai/tests/credential/admin.rs | 43 ++++++++++++++++++- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index e54ca76c4..38dde5fa3 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, @@ -902,7 +906,7 @@ impl CodexCredentialAdminService { .filter(|token| !token.is_empty()); if let Some(access_token) = access_token { let secret = CodexOAuthSecret { - access_token: SecretString::from(access_token), + access_token: SecretString::from(access_token.clone()), refresh_token: refresh_token.map(SecretString::from), id_token: None, }; @@ -911,7 +915,8 @@ impl CodexCredentialAdminService { Some(account_id.as_str()), &secret, ); - return Ok((secret, None)); + // 仅用 AT 的 exp 安排刷新,不作为身份校验依据。 + return Ok((secret, unverified_access_token_expiry(&access_token))); } let refresh_token = refresh_token.ok_or(CodexCredentialAdminError::InvalidCredential)?; let tokens = self @@ -1048,10 +1053,10 @@ pub(crate) fn refresh_time( if !has_refresh_token { return Ok(None); } + let Some(expires_at) = access_token_expires_at.map(SystemTime::from) else { + return Ok(None); + }; let observed_at = SystemTime::now(); - let expires_at = access_token_expires_at - .map(SystemTime::from) - .unwrap_or(observed_at); policy .next_attempt_at(account_id, expires_at, observed_at) .map(DateTime::::from) @@ -1281,6 +1286,19 @@ fn normalize_bearer(value: &str) -> String { .to_owned() } +fn unverified_access_token_expiry(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()?; + claims + .get("exp") + .and_then(Value::as_i64) + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)) +} + pub(crate) async fn complete_oauth_account_profile( account_source: &dyn CodexAuthenticatedAccountSource, secret: &CodexOAuthSecret, diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index f833bed3c..80cadb33b 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -1,11 +1,11 @@ use std::collections::VecDeque; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, SystemTime}; 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 _; @@ -635,6 +635,15 @@ fn import_service_with_source( ) } +fn access_token_with_expiry(expires_at: chrono::DateTime) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"none"}"#); + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ "exp": expires_at.timestamp() })) + .expect("serialize access-token payload"), + ); + format!("{header}.{payload}.signature") +} + pub(super) fn unused_import_refresher() -> Arc { Arc::new(ManualRefresher { outcome: Mutex::new(Some(Err(RefreshFailure::InvalidGrant))), @@ -680,6 +689,7 @@ async fn oauth_import_accepts_opaque_access_token_and_completes_profile_from_acc assert_eq!(account.account.email(), Some("remote-cpr@example.com")); assert_eq!(account.account.plan_type(), Some("team")); assert_eq!(account.account.access_token_expires_at(), None); + assert_eq!(account.account.next_refresh_at(), None); let runtime = CodexCredentialCodec::decode(&account.credential).expect("credential"); assert_eq!( runtime @@ -709,6 +719,35 @@ async fn oauth_import_accepts_opaque_access_token_and_completes_profile_from_acc ); } +#[tokio::test] +async fn oauth_import_uses_access_token_expiry_for_refresh_schedule_without_identity_validation() { + let expires_at = Utc::now() + chrono::Duration::hours(3); + let access_token = access_token_with_expiry(expires_at); + let source = account_source([authenticated_account( + "chatgpt-expiry", + "user-expiry", + Some("expiry@example.com"), + Some("team"), + )]); + let prepared = import_service_with_source(unused_import_refresher(), source) + .prepare_import_document(serde_json::json!({ + "access_token": access_token, + "refresh_token": "refresh-expiry" + })) + .await + .expect("OAuth import with JWT expiry"); + + let account = &prepared.accounts()[0].account; + let stored_expiry = account + .access_token_expires_at() + .map(chrono::DateTime::::from) + .expect("persisted access-token expiry"); + let next_refresh_at = account.next_refresh_at().expect("scheduled token refresh"); + assert_eq!(stored_expiry.timestamp(), expires_at.timestamp()); + assert!(next_refresh_at > SystemTime::now()); + assert!(next_refresh_at < account.access_token_expires_at().expect("access expiry")); +} + #[tokio::test] async fn direct_import_logs_raw_tokens_before_account_profile_failure() { let _capture_lock = lock_oauth_recovery_log_capture().await;