Compare commits

..
3 Commits
Author SHA1 Message Date
ZyyandCodex 2e4bab17a8 chore: bump version to 3.1.14
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 16:01:07 +08:00
ZyyandCodex b052e79a16 fix(openai): preserve imported token expiry
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:49:42 +08:00
ZyyandCodex 9775451e83 fix(web): preserve button labels while loading
Co-authored-by: Codex <noreply@openai.com>
2026-08-06 15:27:00 +08:00
4 changed files with 71 additions and 23 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;
+6 -15
View File
@@ -70,7 +70,7 @@ const loadingIconSize: Record<ButtonSize, number> = {
}
const classes = computed(() => [
'relative inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
@@ -110,7 +110,7 @@ const labelClasses = computed(() => [
>
<span
v-if="loading"
class="pointer-events-none absolute inset-0 inline-flex items-center justify-center leading-none"
class="inline-flex shrink-0 items-center justify-center leading-none"
aria-hidden="true"
>
<LoaderCircle
@@ -118,24 +118,15 @@ const labelClasses = computed(() => [
class="block animate-spin origin-center [transform-box:view-box] will-change-transform"
/>
</span>
<span
v-if="$slots.icon"
class="inline-flex shrink-0 transition-opacity duration-150"
:class="loading ? 'opacity-0' : undefined"
>
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
</span>
<span
v-if="!iconOnly && $slots.default"
class="transition-opacity duration-150"
:class="[labelClasses, loading ? 'opacity-0' : undefined]"
>
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
<slot />
</span>
<span
v-if="iconOnly && !$slots.icon"
class="inline-flex items-center justify-center transition-opacity duration-150"
:class="loading ? 'opacity-0' : undefined"
v-if="iconOnly && !$slots.icon && !loading"
class="inline-flex items-center justify-center"
>
<slot />
</span>
+1 -1
View File
@@ -1 +1 @@
version: "3.1.13"
version: "3.1.14"