mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4bab17a8 | ||
|
|
b052e79a16 | ||
|
|
9775451e83 |
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
version: "3.1.13"
|
||||
version: "3.1.14"
|
||||
|
||||
Reference in New Issue
Block a user