Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 24 additions & 20 deletions authlib/integrations/httpx_client/oauth2_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from authlib.oauth2.client import OAuth2Client as _OAuth2Client

from ..base_client import InvalidTokenError
from ..base_client import MissingTokenError
from ..base_client import OAuthError
from ..base_client import UnsupportedTokenTypeError
from .utils import HTTPX_CLIENT_KWARGS
Expand Down Expand Up @@ -109,11 +108,7 @@ async def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()

await self.ensure_active_token(self.token)

await self.ensure_active_token()
auth = self.token_auth

return await super().request(method, url, auth=auth, **kwargs)
Expand All @@ -123,17 +118,32 @@ async def stream(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()

await self.ensure_active_token(self.token)

await self.ensure_active_token()
auth = self.token_auth

async with super().stream(method, url, auth=auth, **kwargs) as resp:
yield resp

async def ensure_active_token(self, token):
async def ensure_active_token(self, token=None):
if token is None:
if not self.token:
if self.metadata.get("grant_type") == "client_credentials":
await self.fetch_token()
else:
# Lazy import to avoid circular dependency:
# authlib/oauth2/client.py
# → authlib/integrations/base_client/__init__.py (imports sync_openid)
# → authlib/integrations/base_client/sync_openid.py (imports authlib.oidc.core)
# → authlib/oidc/core/__init__.py (imports .grants)
# → authlib/oidc/core/grants/__init__.py (imports .code)
# → authlib/oidc/core/grants/code.py (imports ._legacy)
# → authlib/oidc/core/grants/_legacy.py (imports authlib.oauth2)
# → authlib/oauth2/__init__.py (imports .client) → CIRCULAR
# Python triggers __init__.py even when importing a submodule directly.
from authlib.integrations.base_client.errors import MissingTokenError

raise MissingTokenError()
token = self.token
async with self._token_refresh_lock:
if self.token.is_expired(leeway=self.leeway):
refresh_token = token.get("refresh_token")
Expand Down Expand Up @@ -260,10 +270,7 @@ def request(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()

if not self.ensure_active_token(self.token):
if not self.ensure_active_token():
raise InvalidTokenError()

auth = self.token_auth
Expand All @@ -274,10 +281,7 @@ def stream(
self, method, url, withhold_token=False, auth=USE_CLIENT_DEFAULT, **kwargs
):
if not withhold_token and auth is USE_CLIENT_DEFAULT:
if not self.token:
raise MissingTokenError()

if not self.ensure_active_token(self.token):
if not self.ensure_active_token():
raise InvalidTokenError()

auth = self.token_auth
Expand Down
3 changes: 0 additions & 3 deletions authlib/integrations/requests_client/oauth2_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from authlib.oauth2.client import OAuth2Client

from ..base_client import InvalidTokenError
from ..base_client import MissingTokenError
from ..base_client import OAuthError
from ..base_client import UnsupportedTokenTypeError
from .utils import update_session_configure
Expand Down Expand Up @@ -134,7 +133,5 @@ def request(self, method, url, withhold_token=False, auth=None, **kwargs):
if self.default_timeout:
kwargs.setdefault("timeout", self.default_timeout)
if not withhold_token and auth is None:
if not self.token:
raise MissingTokenError()
Comment on lines -137 to -138

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the if not self.token: raise MissingTokenError() was removed from requests_client/oauth2_session.py's request() method:

The token check and auto-fetch is now handled automatically by the auth layer. When request() sets auth = self.token_auth, it creates an OAuth2Auth instance. During the request, OAuth2Auth.__call__() invokes self.client.ensure_active_token(self.token), which:

  1. If token is None and grant_type="client_credentials" → calls self.fetch_token() automatically
  2. If token is None and no auto-fetch is possible → raises MissingTokenError
  3. If token exists but is expired → refreshes it

So the explicit check in request() was redundant — OAuth2Auth already triggers ensure_active_token() which handles all cases.

auth = self.token_auth
return super().request(method, url, auth=auth, **kwargs)
17 changes: 17 additions & 0 deletions authlib/oauth2/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,23 @@ def refresh_token(

def ensure_active_token(self, token=None):
if token is None:
if not self.token:
if self.metadata.get("grant_type") == "client_credentials":
self.fetch_token()
else:
# Lazy import to avoid circular dependency:
# authlib/oauth2/client.py (this file)
# → authlib/integrations/base_client/__init__.py (imports sync_openid)
# → authlib/integrations/base_client/sync_openid.py (imports authlib.oidc.core)
# → authlib/oidc/core/__init__.py (imports .grants)
# → authlib/oidc/core/grants/__init__.py (imports .code)
# → authlib/oidc/core/grants/code.py (imports ._legacy)
# → authlib/oidc/core/grants/_legacy.py (imports authlib.oauth2)
# → authlib/oauth2/__init__.py (imports .client) → CIRCULAR
# Python triggers __init__.py even when importing a submodule directly.
from authlib.integrations.base_client.errors import MissingTokenError

raise MissingTokenError()
token = self.token
if not token.is_expired(leeway=self.leeway):
return True
Expand Down
63 changes: 63 additions & 0 deletions tests/clients/test_httpx/test_async_oauth2_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,66 @@ async def test_request_without_token():
async with AsyncOAuth2Client("a", transport=transport) as client:
with pytest.raises(OAuthError):
await client.get("https://provider.test/token")


@pytest.mark.asyncio
async def test_client_credentials_auto_fetch_token_on_first_request():
transport = ASGITransport(AsyncMockDispatch(default_token))
async with AsyncOAuth2Client(
"foo",
client_secret="bar",
token_endpoint="https://provider.test/token",
grant_type="client_credentials",
transport=transport,
) as client:
await client.get("https://provider.test/resource")
assert client.token["access_token"] == default_token["access_token"]


@pytest.mark.asyncio
async def test_non_client_credentials_raises_missing_token():
transport = ASGITransport(AsyncMockDispatch())
async with AsyncOAuth2Client("foo", transport=transport) as client:
with pytest.raises(OAuthError):
await client.get("https://provider.test/resource")


@pytest.mark.asyncio
async def test_client_credentials_auto_fetch_token_on_first_stream():
transport = ASGITransport(AsyncMockDispatch(default_token))
async with AsyncOAuth2Client(
"foo",
client_secret="bar",
token_endpoint="https://provider.test/token",
grant_type="client_credentials",
transport=transport,
) as client:
async with client.stream("GET", "https://provider.test/resource") as stream:
await stream.aread()
assert client.token["access_token"] == default_token["access_token"]


@pytest.mark.asyncio
async def test_non_client_credentials_raises_missing_token_on_stream():
transport = ASGITransport(AsyncMockDispatch())
async with AsyncOAuth2Client("foo", transport=transport) as client:
with pytest.raises(OAuthError):
async with client.stream("GET", "https://provider.test/resource") as stream:
await stream.aread()


@pytest.mark.asyncio
async def test_ensure_active_token_with_provided_token():
transport = ASGITransport(AsyncMockDispatch(default_token))
async with AsyncOAuth2Client(
"foo",
token_endpoint="https://provider.test/token",
grant_type="client_credentials",
transport=transport,
) as client:
token = deepcopy(default_token)
token["expires_at"] = time.time() + 3600
client.token = token
with mock.patch.object(client, "fetch_token") as mocked:
await client.ensure_active_token(token)
mocked.assert_not_called()
42 changes: 42 additions & 0 deletions tests/clients/test_httpx/test_oauth2_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,45 @@ def test_request_without_token():
with OAuth2Client("a", transport=transport) as client:
with pytest.raises(OAuthError):
client.get("https://provider.test/token")


def test_client_credentials_auto_fetch_token_on_first_request():
transport = WSGITransport(MockDispatch(default_token))
with OAuth2Client(
"foo",
client_secret="bar",
token_endpoint="https://provider.test/token",
grant_type="client_credentials",
transport=transport,
) as client:
client.get("https://provider.test/resource")
assert client.token["access_token"] == default_token["access_token"]


def test_non_client_credentials_raises_missing_token():
transport = WSGITransport(MockDispatch())
with OAuth2Client("foo", transport=transport) as client:
with pytest.raises(OAuthError):
client.get("https://provider.test/resource")


def test_client_credentials_auto_fetch_token_on_first_stream():
transport = WSGITransport(MockDispatch(default_token))
with OAuth2Client(
"foo",
client_secret="bar",
token_endpoint="https://provider.test/token",
grant_type="client_credentials",
transport=transport,
) as client:
with client.stream("GET", "https://provider.test/resource") as stream:
stream.read()
assert client.token["access_token"] == default_token["access_token"]


def test_non_client_credentials_raises_missing_token_on_stream():
transport = WSGITransport(MockDispatch())
with OAuth2Client("foo", transport=transport) as client:
with pytest.raises(OAuthError):
with client.stream("GET", "https://provider.test/resource") as stream:
stream.read()
29 changes: 29 additions & 0 deletions tests/clients/test_requests/test_oauth2_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,32 @@ def verifier(r, **kwargs):
client.request(
"GET", "https://provider.test", withhold_token=False, timeout=expected_timeout
)


def test_client_credentials_auto_fetch_token_on_first_request(token):
url = "https://provider.test/token"

def fake_send(r, **kwargs):
resp = mock.MagicMock()
resp.status_code = 200
if "grant_type=client_credentials" in (r.body or ""):
resp.json = lambda: token
return resp

sess = OAuth2Session(
client_id="foo",
client_secret="bar",
token_endpoint=url,
grant_type="client_credentials",
)
sess.send = fake_send
sess.get("https://provider.test/resource")
assert sess.token["access_token"] == token["access_token"]


def test_non_client_credentials_raises_missing_token():
from authlib.integrations.base_client import MissingTokenError

sess = OAuth2Session(client_id="foo")
with pytest.raises(MissingTokenError):
sess.get("https://provider.test/resource")
Loading