Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,26 @@ client.auth.client.set_bearer_authorization(access_token)
# Now you can make authenticated requests
```

### 401 Auto-Refresh

`with_user_session()` installs a one-shot 401 auto-refresh hook on the auth and api clients by default (issue #89): when a request comes back 401 because the bearer expired mid-session, the session token is force-refreshed once and the request retried with the new Authorization header. If the refresh fails, the original 401 surfaces to the caller as before. Pass `refresh_on_401=False` for the old behaviour.

```python
with campus.with_user_session() as client: # hook on (default)
...

with campus.with_user_session(refresh_on_401=False) as client: # hook off
...
```

The retry cannot double-execute work: Campus services authenticate requests in a `before_request` hook before any handler runs, so a 401 response means no handler executed.

Raw `CampusRequest` users can install their own hook (e.g. public clients driving `auth.refresh(stored)`):

```python
client.set_unauthorized_hook(lambda: campus.auth.refresh(stored, client_id="campus-cli").access_token)
```

## Service Base URLs

Each service client (`campus.auth`, `campus.api`, `campus.audit`) resolves its base URL in this order:
Expand Down
65 changes: 61 additions & 4 deletions campus_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,21 +243,78 @@ def with_app_session(self) -> Iterator["Campus"]:
self.revoke_session()

@contextmanager
def with_user_session(self) -> Iterator["Campus"]:
def with_user_session(
self,
*,
refresh_on_401: bool = True,
) -> Iterator["Campus"]:
"""Context manager yielding CampusRequest with user credentials.

Usage:
with campus.with_user_session() as client:
# use client for requests

By default a 401 auto-refresh hook is installed on the auth and
api clients for the duration of the session (issue #89): when a
request comes back 401 because the bearer expired after the
proactive refresh below, the token is force-refreshed once and
the request retried with the new Authorization header. The
retry cannot double-execute work — Campus services reject
unauthenticated requests in a before_request authenticator
before any handler runs. If the refresh itself fails, the
original 401 response surfaces to the caller as before. Pass
refresh_on_401=False for the old behaviour.

Yields:
CampusRequest: JSON client with user credentials set.
Campus: JSON client with user credentials set.
"""
token = self._get_token_from_session()
self.use_token(token)

hooked_clients = []
if refresh_on_401:
refreshing = False

def refresh_bearer() -> str | None:
"""Force-refresh the session token once.

Returns the new bearer token for the client to retry
with, or None on failure (the original 401 then
surfaces). Re-entrant calls (the refresh round-trip
itself hitting a 401) bail out immediately.

The refresh round-trip authenticates as the client
(Basic), the same mode _get_token_from_session runs in
at session establishment — auth routes accept both,
but the session's stale bearer must not be presented
mid-rotation.
"""
nonlocal refreshing
if refreshing:
return None
refreshing = True
try:
self.revoke_session()
refreshed = self._get_token_from_session(
force_refresh=True
)
except errors.APIError:
self.use_token(token)
return None
finally:
refreshing = False
self.use_token(refreshed)
return refreshed.access_token

for client in (self.api.client, self.auth.client):
client.set_unauthorized_hook(refresh_bearer)
hooked_clients.append(client)

try:
token = self._get_token_from_session()
self.use_token(token)
yield self
except Exception:
raise
finally:
for client in hooked_clients:
client.set_unauthorized_hook(None)
self.revoke_session()
106 changes: 68 additions & 38 deletions campus_python/json_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
]

import base64
from collections.abc import Callable
from typing import Any, Mapping, Self, cast

import campus.model
Expand Down Expand Up @@ -87,6 +88,10 @@ def __init__(
self._headers = dict(headers or {})
# allow optional default timeout via kwargs
self._timeout = kwargs.get("timeout", 10)
# Optional 401 auto-refresh hook (issue #89); see
# set_unauthorized_hook(). Off by default.
self._unauthorized_hook: Callable[[], str | None] | None = None
self._in_unauthorized_hook = False
# Session to persist headers and connection pooling
self._session = requests.Session()
self._session.headers.update(self._headers)
Expand Down Expand Up @@ -140,34 +145,77 @@ def set_bearer_authorization(self, token: str) -> None:
"""
self._session.headers["Authorization"] = "Bearer " + token

def set_unauthorized_hook(
self,
hook: Callable[[], str | None] | None,
) -> None:
"""Install a 401 auto-refresh hook (issue #89).

When a request comes back 401, the hook is invoked once; if it
returns a bearer token, the Authorization header is refreshed
and the request is retried once with it. A second 401 on the
retry is returned to the caller as-is, and a hook returning
None (refresh failed) leaves the original 401 response in
place — failures surface exactly as they would without the
hook.

The hook runs at most once per request, and never re-enters
itself: requests made from inside the hook skip the hook, so a
hook that calls back into this client cannot recurse.

Args:
hook: Callable returning the new bearer token, or None to
signal refresh failure. None clears the hook.
"""
self._unauthorized_hook = hook

def _send(self, method: str, url: str, **kwargs: Any) -> JsonResponse:
"""Send a request, with one 401-triggered retry (issue #89).

The retry is safe even for non-idempotent requests: Campus
services validate the bearer in a before_request authenticator
before dispatching, so a 401 response is produced by the auth
layer before any handler runs — there is no partial work to
replay.
"""
try:
resp = self._session.request(
method, url, timeout=self._timeout, **kwargs
)
if (
resp.status_code == 401
and self._unauthorized_hook is not None
and not self._in_unauthorized_hook
):
self._in_unauthorized_hook = True
try:
token = self._unauthorized_hook()
finally:
self._in_unauthorized_hook = False
if token:
self.set_bearer_authorization(token)
resp = self._session.request(
method, url, timeout=self._timeout, **kwargs
)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)

def get(
self: Self,
path: str,
query: JsonDict | None = None
) -> JsonResponse:
"""Sends a GET request."""
url = self._build_url(path)
try:
if query:
resp = self._session.get(
url,
params=query,
timeout=self._timeout
)
else:
resp = self._session.get(url, timeout=self._timeout)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)
if query:
return self._send("GET", url, params=query)
return self._send("GET", url)

def post(self: Self, path: str, json: JsonDict | None = None) -> JsonResponse:
"""Sends a POST request."""
url = self._build_url(path)
try:
resp = self._session.post(url, json=json, timeout=self._timeout)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)
return self._send("POST", url, json=json)

def put(
self: Self,
Expand All @@ -177,13 +225,7 @@ def put(
) -> JsonResponse:
"""Sends a PUT request."""
url = self._build_url(path)
try:
resp = self._session.put(
url, json=json, params=query, timeout=self._timeout
)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)
return self._send("PUT", url, json=json, params=query)

def delete(
self: Self,
Expand All @@ -193,13 +235,7 @@ def delete(
) -> JsonResponse:
"""Sends a DELETE request."""
url = self._build_url(path)
try:
resp = self._session.delete(
url, json=json, params=query, timeout=self._timeout
)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)
return self._send("DELETE", url, json=json, params=query)

def patch(
self: Self,
Expand All @@ -209,10 +245,4 @@ def patch(
) -> JsonResponse:
"""Sends a PATCH request."""
url = self._build_url(path)
try:
resp = self._session.patch(
url, json=json, params=query, timeout=self._timeout
)
except requests.RequestException as exc:
raise errors.ServerError(error_description=str(exc)) from None
return CampusResponse(resp)
return self._send("PATCH", url, json=json, params=query)
6 changes: 6 additions & 0 deletions campus_python/json_client/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ def raise_for_status(self) -> None:
class JsonClient(ABC):
"""This class describes the public interface required from Client
classes, which are used to send JSON requests.

Optional capability, not part of this abstract interface: concrete
clients may support a 401 auto-refresh hook
(CampusRequest.set_unauthorized_hook, issue #89) that refreshes the
bearer token and retries a request once when it comes back 401.
Callers must feature-detect (hasattr) rather than assume it.
"""
base_url: str
# pylint: disable=unnecessary-ellipsis
Expand Down
21 changes: 11 additions & 10 deletions tests/unit/test_json_client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,33 +23,34 @@ def setUp(self):
base_url="https://auth.example.test", mode="device"
)

def _assert_params_passed(self, verb: str, session_method: mock.Mock):
def _assert_params_passed(self, verb: str, expected_method: str):
self.client._timeout = 5
with mock.patch.object(
self.client._session, session_method,
return_value=mock.Mock()) as method:
self.client._session, "request",
return_value=mock.Mock()) as request:
getattr(self.client, verb)(
"/some/path", json={"k": "v"}, query={"user_id": "u"})
_, kwargs = method.call_args
args, kwargs = request.call_args
self.assertEqual(args[0], expected_method.upper())
self.assertEqual(kwargs["json"], {"k": "v"})
self.assertEqual(kwargs["params"], {"user_id": "u"})

def test_put_passes_query_as_params(self):
self._assert_params_passed("put", "put")
self._assert_params_passed("put", "PUT")

def test_delete_passes_query_as_params(self):
self._assert_params_passed("delete", "delete")
self._assert_params_passed("delete", "DELETE")

def test_patch_passes_query_as_params(self):
self._assert_params_passed("patch", "patch")
self._assert_params_passed("patch", "PATCH")

def test_query_defaults_to_none(self):
"""Omitting query= sends params=None, preserving old behavior."""
with mock.patch.object(
self.client._session, "delete",
return_value=mock.Mock()) as delete:
self.client._session, "request",
return_value=mock.Mock()) as request:
self.client.delete("/some/path")
self.assertIsNone(delete.call_args.kwargs["params"])
self.assertIsNone(request.call_args.kwargs["params"])


if __name__ == "__main__":
Expand Down
Loading
Loading