mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -49,7 +49,9 @@ use crate::credential::{
|
||||
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
|
||||
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
};
|
||||
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
|
||||
use crate::credential::{
|
||||
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
|
||||
};
|
||||
use crate::transport::CodexWebSocketPool;
|
||||
use crate::transport::profile::{
|
||||
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
|
||||
@@ -380,8 +382,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
.oauth()
|
||||
.and_then(|oauth| oauth.id_token.clone());
|
||||
}
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let prepared = CodexCredentialAdmin
|
||||
.prepare_refreshed_oauth_rotation(current, secret, None, None)
|
||||
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
|
||||
.map_err(map_credential_admin_error)?;
|
||||
prepared_rotation(prepared, command.account.provider_kind)
|
||||
}
|
||||
|
||||
@@ -819,7 +819,13 @@ impl CodexCredentialAdminService {
|
||||
.await
|
||||
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
|
||||
.map_err(map_refresh_failure)?;
|
||||
let access_token_rotated = tokens.access_token.is_some();
|
||||
let access_token_expires_at = match tokens.access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token),
|
||||
None => current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
};
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: tokens
|
||||
.access_token
|
||||
@@ -839,17 +845,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from)
|
||||
} else {
|
||||
current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from)
|
||||
};
|
||||
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
|
||||
// 之前瞬态失败留下的 retry-not-before。
|
||||
let next_refresh_at = None;
|
||||
@@ -1016,6 +1011,7 @@ impl CodexCredentialAdminService {
|
||||
let access_token = tokens
|
||||
.access_token
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let access_token_expires_at = parse_access_token_expiration(&access_token);
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -1030,10 +1026,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
Ok((secret, access_token_expires_at))
|
||||
}
|
||||
|
||||
@@ -1173,6 +1165,8 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
|
||||
failure_class = error.classification(),
|
||||
upstream_message = ?error.message(),
|
||||
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
|
||||
"OpenAI OAuth manual refresh failed"
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
|
||||
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
|
||||
};
|
||||
pub(crate) use oauth::oauth_owner_ref;
|
||||
pub(crate) use types::parse_access_token_expiration;
|
||||
|
||||
pub use agent_identity::{
|
||||
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Codex Authorization Code + PKCE/OIDC 管理流。
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -35,7 +31,7 @@ use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
};
|
||||
use super::types::parse_chatgpt_jwt_claims;
|
||||
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
|
||||
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
|
||||
@@ -527,13 +523,12 @@ impl CodexOAuthAdminService {
|
||||
.map(current_oauth_refresh_token)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let (mut secret, id_token, expires_in) = self
|
||||
let (mut secret, id_token) = self
|
||||
.exchange_pending(&pending, callback_url, fallback_refresh_token)
|
||||
.await?;
|
||||
let mutation = pending.mutation.clone();
|
||||
let access_token_expires_at = expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
|
||||
secret.id_token = Some(id_token);
|
||||
@@ -671,14 +666,7 @@ impl CodexOAuthAdminService {
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
fallback_refresh_token: Option<SecretString>,
|
||||
) -> Result<
|
||||
(
|
||||
super::types::CodexOAuthSecret,
|
||||
SecretString,
|
||||
Option<Duration>,
|
||||
),
|
||||
CodexOAuthAdminError,
|
||||
> {
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
if !constant_time_equal(
|
||||
pending.state.expose_secret().as_bytes(),
|
||||
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
Ok((secret, tokens.id_token, tokens.expires_in))
|
||||
Ok((secret, tokens.id_token))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use thiserror::Error;
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
|
||||
use super::types::CodexOAuthSecret;
|
||||
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const MAX_REFRESH_BATCH: u32 = 1_000;
|
||||
@@ -101,6 +101,8 @@ fn log_refresh_deferred(
|
||||
reason,
|
||||
upstream_message = ?upstream_message,
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
retry_at = %DateTime::<Utc>::from(retry_at),
|
||||
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
|
||||
@@ -437,9 +439,11 @@ impl CodexCredentialRefreshService {
|
||||
access_token,
|
||||
refresh_token: rotated_refresh_token,
|
||||
id_token: rotated_id_token,
|
||||
expires_in,
|
||||
} = tokens;
|
||||
let access_token_rotated = access_token.is_some();
|
||||
let access_token_expires_at = match access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
|
||||
None => due.account.access_token_expires_at(),
|
||||
};
|
||||
let CodexOAuthSecret {
|
||||
access_token: current_access_token,
|
||||
refresh_token: current_refresh_token,
|
||||
@@ -466,12 +470,6 @@ impl CodexCredentialRefreshService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
let observed_at = SystemTime::now();
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
expires_in.and_then(|value| observed_at.checked_add(value))
|
||||
} else {
|
||||
due.account.access_token_expires_at()
|
||||
};
|
||||
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
|
||||
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
|
||||
let next_refresh_at = None;
|
||||
@@ -553,6 +551,8 @@ impl CodexCredentialRefreshService {
|
||||
reason,
|
||||
upstream_message = ?message.as_deref(),
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
access_token_expires_at = ?account.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
|
||||
@@ -27,9 +27,6 @@ pub struct TokenPair {
|
||||
pub refresh_token: Option<String>,
|
||||
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
|
||||
pub id_token: Option<String>,
|
||||
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
|
||||
/// exchange,只意味着调用方不能据此安排下一次刷新。
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenPair {
|
||||
@@ -45,7 +42,6 @@ impl fmt::Debug for TokenPair {
|
||||
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -121,13 +117,21 @@ impl RefreshFailure {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RefreshUpstreamFailure {
|
||||
status: u16,
|
||||
code: Option<String>,
|
||||
error_type: Option<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl RefreshUpstreamFailure {
|
||||
fn new(status: StatusCode, body: &[u8]) -> Self {
|
||||
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
|
||||
Self {
|
||||
status: status.as_u16(),
|
||||
code: error
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_owned),
|
||||
error_type: error
|
||||
.and_then(RefreshErrorResponse::error_type)
|
||||
.map(str::to_owned),
|
||||
body: String::from_utf8_lossy(body).into_owned(),
|
||||
}
|
||||
}
|
||||
@@ -137,6 +141,16 @@ impl RefreshUpstreamFailure {
|
||||
self.status
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn code(&self) -> Option<&str> {
|
||||
self.code.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_type(&self) -> Option<&str> {
|
||||
self.error_type.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(&self) -> &str {
|
||||
&self.body
|
||||
@@ -148,6 +162,8 @@ impl fmt::Debug for RefreshUpstreamFailure {
|
||||
formatter
|
||||
.debug_struct("RefreshUpstreamFailure")
|
||||
.field("status", &self.status)
|
||||
.field("code", &self.code)
|
||||
.field("error_type", &self.error_type)
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
@@ -182,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
|
||||
pub struct AuthorizationTokenSet {
|
||||
pub secret: crate::credential::CodexOAuthSecret,
|
||||
pub id_token: SecretString,
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationTokenSet {
|
||||
@@ -191,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
|
||||
.debug_struct("AuthorizationTokenSet")
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("id_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -273,7 +287,6 @@ struct RefreshTokenResponse {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -300,6 +313,8 @@ enum RefreshError {
|
||||
struct RefreshErrorDetails {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshErrorResponse {
|
||||
@@ -319,6 +334,13 @@ impl RefreshErrorResponse {
|
||||
}
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn error_type(&self) -> Option<&str> {
|
||||
match self.error.as_ref() {
|
||||
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
|
||||
Some(RefreshError::Code(_)) | None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -326,7 +348,6 @@ struct AuthorizationCodeResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
id_token: String,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -398,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -447,36 +467,37 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
|
||||
refresh_token: tokens.refresh_token,
|
||||
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
|
||||
id_token: tokens.id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
|
||||
value
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
|
||||
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
|
||||
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
|
||||
let message = Some(refresh_failure_message(error.as_ref(), body));
|
||||
let upstream = || Some(Box::new(RefreshUpstreamFailure::new(status, body)));
|
||||
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
|
||||
// 不据此终态失效账号,但官方 `error.message` 仍用于本次诊断。
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return RefreshFailure::Transport {
|
||||
let upstream = || {
|
||||
Some(Box::new(RefreshUpstreamFailure::new(
|
||||
status,
|
||||
body,
|
||||
error.as_ref(),
|
||||
)))
|
||||
};
|
||||
let normalized_code = error
|
||||
.as_ref()
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_ascii_lowercase);
|
||||
// 与官方 Codex 相同:三个明确的 RT 原因在任意 HTTP 状态下都是永久失败;
|
||||
// 此外任意 401 永久失败,其他未知 OAuth code 保持暂态。
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
|
||||
) || status == StatusCode::UNAUTHORIZED
|
||||
{
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
let Some(error) = error else {
|
||||
return RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
};
|
||||
if message.as_deref().is_some_and(|message| {
|
||||
message
|
||||
.to_ascii_lowercase()
|
||||
@@ -487,23 +508,6 @@ fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
let normalized_code = error.code().map(str::to_ascii_lowercase);
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some(
|
||||
"invalid_grant"
|
||||
| "invalid_token"
|
||||
| "access_denied"
|
||||
| "refresh_token_expired"
|
||||
| "refresh_token_reused"
|
||||
| "refresh_token_invalidated"
|
||||
)
|
||||
) {
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::num::NonZeroU32;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
@@ -762,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
|
||||
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_admin_rotation_expiration".to_owned(),
|
||||
name: "admin rotation expiration".to_owned(),
|
||||
secret: secret("admin-rotation-access"),
|
||||
verified_account: profile("chatgpt-admin-rotation-expiration"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account = store
|
||||
.account("acct_admin_rotation_expiration")
|
||||
.expect("stored account");
|
||||
let record = account_record(&account);
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let mut material = Map::new();
|
||||
material.insert(
|
||||
"access_token".to_owned(),
|
||||
Value::String(format!("unverified-header.{payload}.unverified-signature")),
|
||||
);
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
Value::String("admin-rotation-refresh".to_owned()),
|
||||
);
|
||||
|
||||
let prepared = bundle
|
||||
.admin_provider()
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: record,
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
|
||||
})
|
||||
.await
|
||||
.expect("JWT rotation should be prepared");
|
||||
|
||||
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
|
||||
}
|
||||
|
||||
fn provider_ports() -> ProviderStorePorts {
|
||||
provider_ports_with(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
|
||||
@@ -112,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from(self.id_token.clone()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
@@ -36,7 +37,6 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -52,7 +52,17 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: Some(id_token.to_owned()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn with_access_token(access_token: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
response: Mutex::new(Some(TokenPair {
|
||||
access_token: Some(access_token),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -64,7 +74,6 @@ impl SingleUseRefresher {
|
||||
access_token: None,
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -361,6 +370,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let refresher = SingleUseRefresher::with_access_token(format!(
|
||||
"unverified-header.{payload}.unverified-signature"
|
||||
));
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = refresh_service(
|
||||
&store,
|
||||
refresher,
|
||||
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
|
||||
);
|
||||
let account_id = "acct_rotated_access_expiration";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("refreshed account")
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
Some(expires_at)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use provider_openai::credential::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
|
||||
@@ -55,7 +53,7 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
@@ -77,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
|
||||
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -122,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
|
||||
assert!(tokens.access_token.is_none());
|
||||
assert!(tokens.refresh_token.is_none());
|
||||
assert!(tokens.id_token.is_none());
|
||||
assert!(tokens.expires_in.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -305,23 +301,50 @@ async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
|
||||
assert_eq!(message.as_deref(), Some(upstream_message));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 400);
|
||||
assert_eq!(upstream.code(), Some("refresh_token_reused"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_error_without_nested_message_should_use_the_complete_body() {
|
||||
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
|
||||
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
|
||||
let failure = refresh_failure(400, body).await;
|
||||
let diagnostic = format!("{failure:?} {failure}");
|
||||
|
||||
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
|
||||
panic!("invalid_grant must be terminal");
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("unknown official refresh code must remain transient");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some(body));
|
||||
assert_eq!(upstream.expect("complete upstream failure").body(), body);
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.code(), Some("invalid_grant"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
assert!(!diagnostic.contains("error_description"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_without_a_known_refresh_code_should_be_permanent() {
|
||||
let body = r#"{
|
||||
"error": {
|
||||
"message": "Invalid refresh token.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "invalid_refresh_token"
|
||||
}
|
||||
}"#;
|
||||
let failure = refresh_failure(401, body).await;
|
||||
|
||||
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
|
||||
panic!("official Codex treats every 401 as permanent");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 401);
|
||||
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivated_account_should_be_classified_as_banned() {
|
||||
let failure = refresh_failure(
|
||||
@@ -366,24 +389,29 @@ async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
|
||||
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
|
||||
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
|
||||
async fn official_refresh_code_should_be_permanent_regardless_of_http_status() {
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(
|
||||
status,
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_transport_failure(
|
||||
&failure,
|
||||
status,
|
||||
"Refresh token expired.",
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
assert!(
|
||||
matches!(failure, RefreshFailure::InvalidGrant { .. }),
|
||||
"official refresh code must take precedence for status {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
|
||||
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(status, body).await;
|
||||
assert_transport_failure(&failure, status, "Try again.", body);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_transport_failure(failure: &RefreshFailure, status: u16, message: &str, body: &str) {
|
||||
let RefreshFailure::Transport {
|
||||
message: actual_message,
|
||||
|
||||
Reference in New Issue
Block a user