mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(openai): preserve imported token expiry
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user