fix(openai): preserve imported token expiry

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-08-06 15:49:42 +08:00
co-authored by Codex
parent 9775451e83
commit b052e79a16
2 changed files with 64 additions and 7 deletions
@@ -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::<Utc>::from)
@@ -1281,6 +1286,19 @@ fn normalize_bearer(value: &str) -> String {
.to_owned()
}
fn unverified_access_token_expiry(token: &str) -> Option<DateTime<Utc>> {
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::<Value>(&decoded).ok()?;
claims
.get("exp")
.and_then(Value::as_i64)
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
}
pub(crate) async fn complete_oauth_account_profile(
account_source: &dyn CodexAuthenticatedAccountSource,
secret: &CodexOAuthSecret,
@@ -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<Utc>) -> 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<ManualRefresher> {
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::<Utc>::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;