Treat a refreshable codex credential as authenticated

get_provider_handler() gates every provider on the synchronous
validate_credentials(), which for codex OAuth2 asks is_authenticated() ->
get_valid_token().  That returns None as soon as the cached access token is
within five minutes of expiry, and deliberately does not refresh.  The request
paths do refresh - get_valid_token_with_refresh() at codex.py:311 and :1188,
writing the new tokens back - but they are never reached, because handler
construction has already raised "credentials not valid or not configured".

So an access token going stale takes the provider out of every rotation, and
nothing puts it back: a provider that cannot be built cannot refresh the token
that would let it be built.  Only a human re-login clears it.  On aisbf.cloud
all six codex providers were in that state - the main one since its token
expired on 2026-09-10 - with working refresh tokens sitting in the credential
files beside them, so rotation/lisa quietly served every request from its
kilo fallback instead of codex.

is_authenticated() now answers the question validate_credentials() is really
asking - can these credentials still produce a token - by accepting a stale
access token when a refresh token is present.  If that refresh later fails the
request fails and the rotation fails over, which is the behaviour it already
has for any other provider error.

kilo is left alone: its get_valid_token_with_refresh() only reloads the file
and cannot refresh, so for kilo an expired token really is terminal.  Qwen has
the same shape as codex, but its OAuth2 path is documented as discontinued.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent c9f71074
...@@ -615,8 +615,23 @@ class CodexOAuth2: ...@@ -615,8 +615,23 @@ class CodexOAuth2:
return None return None
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:
"""Check if user is authenticated with valid token.""" """Whether these credentials can still produce an access token.
return self.get_valid_token() is not None
An expired *cached* access token is not a reason to call the provider
unusable: every request path refreshes through
get_valid_token_with_refresh() and writes the new tokens back. But
validate_credentials() is synchronous, cannot await that, and used to
ask get_valid_token(), which returns None the moment the cached token
goes stale. get_provider_handler() then raised "credentials not valid
or not configured" and the provider was dropped from every rotation -
permanently, since nothing ever refreshes a provider it refuses to
build. On aisbf.cloud all six codex providers were dead that way for
weeks with working refresh tokens sitting in the files beside them.
"""
if self.get_valid_token() is not None:
return True
tokens = (self.credentials or {}).get("tokens") or {}
return bool(tokens.get("refresh_token"))
def get_user_email(self) -> Optional[str]: def get_user_email(self) -> Optional[str]:
"""Get authenticated user's email from ID token.""" """Get authenticated user's email from ID token."""
......
"""A refreshable codex credential must count as authenticated.
validate_credentials() is synchronous, so it asks CodexOAuth2.is_authenticated()
rather than the async get_valid_token_with_refresh() that every request path
uses. While is_authenticated() was just "get_valid_token() is not None", an
expired cached access token made get_provider_handler() raise "credentials not
valid or not configured", which dropped the provider from its rotations for
good - nothing refreshes a provider it refuses to build. On aisbf.cloud all
six codex providers sat dead that way with valid refresh tokens on disk.
"""
import base64
import json
import time
import pytest
from aisbf.auth.codex import CodexOAuth2
def _jwt(exp):
"""Minimal JWT - only the claims segment is ever parsed."""
claims = base64.urlsafe_b64encode(json.dumps({"exp": exp}).encode()).rstrip(b"=")
return "header.%s.signature" % claims.decode()
def _creds(tmp_path, access_exp=None, refresh_token="refresh-abc"):
tokens = {}
if access_exp is not None:
tokens["access_token"] = _jwt(access_exp)
if refresh_token is not None:
tokens["refresh_token"] = refresh_token
path = tmp_path / "codex_credentials.json"
path.write_text(json.dumps({"auth_mode": "codex", "tokens": tokens}))
return CodexOAuth2(credentials_file=str(path))
def test_live_access_token_is_authenticated(tmp_path):
oauth = _creds(tmp_path, access_exp=time.time() + 3600)
assert oauth.get_valid_token() is not None
assert oauth.is_authenticated()
def test_expired_access_token_with_refresh_token_is_authenticated(tmp_path):
"""The regression: stale is not the same as missing."""
oauth = _creds(tmp_path, access_exp=time.time() - 3600)
assert oauth.get_valid_token() is None
assert oauth.is_authenticated()
def test_access_token_inside_the_expiry_buffer_still_counts(tmp_path):
"""get_valid_token() rejects anything expiring within 5 minutes."""
oauth = _creds(tmp_path, access_exp=time.time() + 60)
assert oauth.get_valid_token() is None
assert oauth.is_authenticated()
def test_expired_access_token_without_refresh_token_is_not_authenticated(tmp_path):
oauth = _creds(tmp_path, access_exp=time.time() - 3600, refresh_token=None)
assert not oauth.is_authenticated()
def test_no_tokens_at_all_is_not_authenticated(tmp_path):
oauth = _creds(tmp_path, access_exp=None, refresh_token=None)
assert not oauth.is_authenticated()
def test_missing_credentials_file_is_not_authenticated(tmp_path):
oauth = CodexOAuth2(credentials_file=str(tmp_path / "absent.json"))
assert not oauth.is_authenticated()
@pytest.mark.parametrize("garbage", ["", "not-a-jwt", "a.b"])
def test_unparseable_access_token_falls_back_to_the_refresh_token(tmp_path, garbage):
path = tmp_path / "codex_credentials.json"
path.write_text(json.dumps(
{"auth_mode": "codex",
"tokens": {"access_token": garbage, "refresh_token": "refresh-abc"}}))
oauth = CodexOAuth2(credentials_file=str(path))
assert oauth.get_valid_token() is None
assert oauth.is_authenticated()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment