diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index ce62425e2..6ba1e45a6 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -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) } diff --git a/backend/crates/providers/openai/src/credential/admin.rs b/backend/crates/providers/openai/src/credential/admin.rs index f3e6a6060..7c76d1541 100644 --- a/backend/crates/providers/openai/src/credential/admin.rs +++ b/backend/crates/providers/openai/src/credential/admin.rs @@ -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::::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::::from) - } else { - current - .account - .access_token_expires_at() - .map(DateTime::::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::::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" ); diff --git a/backend/crates/providers/openai/src/credential/mod.rs b/backend/crates/providers/openai/src/credential/mod.rs index 7c85b5382..a0b9cebe1 100644 --- a/backend/crates/providers/openai/src/credential/mod.rs +++ b/backend/crates/providers/openai/src/credential/mod.rs @@ -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, diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 23372e8b0..e71371e34 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -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::::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, - ) -> Result< - ( - super::types::CodexOAuthSecret, - SecretString, - Option, - ), - 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)) } } diff --git a/backend/crates/providers/openai/src/credential/refresh.rs b/backend/crates/providers/openai/src/credential/refresh.rs index 01d34b1ae..91a96cbba 100644 --- a/backend/crates/providers/openai/src/credential/refresh.rs +++ b/backend/crates/providers/openai/src/credential/refresh.rs @@ -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::::from(retry_at), access_token_expires_at = ?access_token_expires_at.map(DateTime::::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::::from), diff --git a/backend/crates/providers/openai/src/credential/token_client.rs b/backend/crates/providers/openai/src/credential/token_client.rs index cbdb3181f..1dc7624ba 100644 --- a/backend/crates/providers/openai/src/credential/token_client.rs +++ b/backend/crates/providers/openai/src/credential/token_client.rs @@ -27,9 +27,6 @@ pub struct TokenPair { pub refresh_token: Option, /// 官方刷新响应省略时由持久化调用方保留当前 ID token。 pub id_token: Option, - /// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token - /// exchange,只意味着调用方不能据此安排下一次刷新。 - pub expires_in: Option, } 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, + error_type: Option, 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", &"") .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, } 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, refresh_token: Option, id_token: Option, - expires_in: Option, } #[derive(Serialize)] @@ -300,6 +313,8 @@ enum RefreshError { struct RefreshErrorDetails { code: Option, message: Option, + #[serde(rename = "type")] + error_type: Option, } 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, } #[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 { refresh_token: tokens.refresh_token, // 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。 id_token: tokens.id_token, - expires_in: optional_expiry(tokens.expires_in), }) } -fn optional_expiry(value: Option) -> Option { - 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::(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(), diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index ec28e39ba..84f39cf61 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -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()), diff --git a/backend/crates/providers/openai/tests/credential/oauth.rs b/backend/crates/providers/openai/tests/credential/oauth.rs index f3bb75c23..d7fd72502 100644 --- a/backend/crates/providers/openai/tests/credential/oauth.rs +++ b/backend/crates/providers/openai/tests/credential/oauth.rs @@ -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)), }) } } diff --git a/backend/crates/providers/openai/tests/credential/refresh.rs b/backend/crates/providers/openai/tests/credential/refresh.rs index 129e6de52..4441b826f 100644 --- a/backend/crates/providers/openai/tests/credential/refresh.rs +++ b/backend/crates/providers/openai/tests/credential/refresh.rs @@ -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 { + 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::::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()); diff --git a/backend/crates/providers/openai/tests/credential/token_client.rs b/backend/crates/providers/openai/tests/credential/token_client.rs index 412ff9f0e..e919b9c47 100644 --- a/backend/crates/providers/openai/tests/credential/token_client.rs +++ b/backend/crates/providers/openai/tests/credential/token_client.rs @@ -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,