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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ client.auth.client.set_bearer_authorization(access_token)

### 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.
`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 re-acquired once via `POST /auth/v1/logins/<session_id>/token` (campus#964) and the request retried with the new Authorization header. If the re-acquisition 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)
Expand Down
75 changes: 29 additions & 46 deletions campus_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,34 +182,19 @@ def integrations(self) -> IntegrationsRoot:
)
return self._integrations

def _get_token_from_session(
self,
force_refresh=False,
refresh_if_expired=True
) -> campus.model.OAuthToken:
"""Get the token from flask session."""
# flask session stores a session id
login_session = self.auth.logins.from_session()
user_id = login_session.user_id
if user_id is None:
raise errors.AuthenticationError(
error_description="Login session has no user id."
)
creds_resource = self.auth.credentials["campus"][user_id]
user_creds = creds_resource.get()
if (
force_refresh
or refresh_if_expired and user_creds.token.is_expired()
):
token = self.auth.token(
grant_type="refresh_token",
refresh_token=user_creds.token.refresh_token
)
self.auth.credentials["campus"][user_id].update(token=token)
# Return the refreshed token — user_creds still holds the
# pre-refresh (now rotated) access token.
return token
return user_creds.token
def _get_token_from_session(self) -> campus.model.OAuthToken:
"""Get the token from flask session.

One endpoint call (campus#963/#964, invariant A10):
POST /logins/<session_id>/token re-acquires the session user's
campus bearer with the client's Basic credentials. The
operator-gated credentials GET → refresh grant → credentials
PATCH legs (#854) are gone, with no fallback to them; the
server rotates an expired grant token itself, so there is no
proactive is_expired() check or write-back here — re-calling
this method (as the 401 hook does) is the refresh.
"""
return self.auth.get_token()

def revoke_session(self) -> None:
"""Revoke the current authorization token."""
Expand Down Expand Up @@ -260,12 +245,13 @@ def with_user_session(
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
re-acquisition below, the token is re-acquired once via the
login-session token route (campus#964) 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 re-acquisition itself fails, the original 401
response surfaces to the caller as before. Pass
refresh_on_401=False for the old behaviour.

Yields:
Expand All @@ -279,28 +265,25 @@ def with_user_session(
refreshing = False

def refresh_bearer() -> str | None:
"""Force-refresh the session token once.
"""Re-acquire 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.
surfaces). Re-entrant calls (the re-acquisition
round-trip itself hitting a 401) bail out immediately.

The round-trip authenticates as the client (Basic):
the login-session token route denies user principals
outright (campus#964/A10), so 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
)
refreshed = self._get_token_from_session()
except errors.APIError:
self.use_token(token)
return None
Expand Down
37 changes: 11 additions & 26 deletions campus_python/auth/v1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,13 @@ def logout(self) -> None:
def get_token(self) -> campus.model.OAuthToken:
"""Convenience method to get access token for a user.

This retrieves the user's credentials which includes their access token.
Allows apps to get a fresh token without re-authenticating via OAuth
if the user has a valid login session.
Re-acquires the login session user's campus bearer via
POST /logins/<session_id>/token (campus#963/#964, invariant
A10): one Basic-authenticated endpoint call in the
session-bound lane — never the operator-gated credentials
routes (#854). An expired grant token is rotated server-side,
so the returned token is always live; the response carries no
refresh token.

Args:
None
Expand All @@ -317,36 +321,17 @@ def get_token(self) -> campus.model.OAuthToken:
OAuthToken for the user

Raises:
AuthenticationError: If no login session or no credentials found
AuthenticationError: If no login session is present
APIError: invalid_grant if the login session or the
user's campus grant is gone — re-login required

Example:
# In a protected view that needs the user's token
token = campus.auth.get_token()
headers = {"Authorization": f"Bearer {token.id}"}
response = requests.get("https://api.example.com/data", headers=headers)
"""
# Get user_id from login session
if not self.logins.has_session():
raise errors.AuthenticationError(
error_description="No login session found. User must log in first."
)
login_session = self.logins.from_session()
user_id = login_session.user_id
if user_id is None:
raise errors.AuthenticationError(
error_description="Login session has no user id."
)

# Retrieve credentials for the user
credentials = self.credentials["campus"][user_id].get()
if not credentials.token:
raise errors.AuthenticationError(
error_description=f"No Campus token found for user {user_id}"
)

# TODO: Auto-refresh if token is expired and refresh_token is available
# For now, return the token as-is
return credentials.token
return self.logins.token()

def _user_cache_get(
self, kind: str, session_id: str
Expand Down
40 changes: 40 additions & 0 deletions campus_python/auth/v1/logins.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ def has_session(self) -> bool:
"""Check if there is a session ID stored in the flask session."""
return self._session_key in flask.session

def token(self) -> campus.model.OAuthToken:
"""Re-acquire the current login session's campus bearer (#117).

One endpoint call (campus#964, invariant A10):
POST /logins/<session_id>/token, authenticated by the client's
Basic credentials plus the session id — user bearers are denied
outright, so the caller must not present one (revoke_session()
first mid-rotation). An expired grant token is rotated
server-side; the response omits the refresh token.

Raises:
AuthenticationError: If no login session is present.
APIError: invalid_grant when the session or the user's
campus grant is gone — the cue to require re-login.
"""
return self[None].token()

def new(
self,
*,
Expand Down Expand Up @@ -112,3 +129,26 @@ def update(self, *, expiry_seconds: int) -> campus.model.LoginSession:
# Raise error if status code is not 2XX or 3XX
resp.raise_for_status()
return campus.model.LoginSession.from_resource(resp.json())

def token(self) -> campus.model.OAuthToken:
"""Re-acquire the session user's campus bearer (#117).

POST /logins/<session_id>/token — the session-bound
re-acquisition lane (campus#964, invariant A10): the login
session plays the authorization code's role, authenticated
with the client's Basic credentials. Issuance mirrors the
code exchange: an unexpired grant token is reused, an
expired one is minted fresh (rotated server-side), and a
missing grant is an invalid_grant, never minted from
nothing (C5). The response omits the refresh token —
campus refresh material never leaves the auth service in
this lane.
"""
# Canonical path has no trailing slash (route rule is
# /<session_id>/token, unlike the CRUD subresources).
resp = self.client.post(
self.make_path("token")
)
# Raise error if status code is not 2XX or 3XX
resp.raise_for_status()
return campus.model.OAuthToken.from_resource(resp.json())
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 4 additions & 19 deletions tests/unit/test_embedded_session_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,6 @@
"user_id": "user-1",
}

AUTH_SESSION_RESOURCE = {
"id": SESSION_ID,
"created_at": "2026-10-01T00:00:00+00:00",
"expires_at": "2026-11-01T00:00:00+00:00",
"provider": "campus",
"client_id": "client-1",
"user_id": "user-1",
"redirect_uri": "https://app.example.org/finalize_login",
"scopes": [],
}


def make_auth() -> tuple[AuthRoot, Mock]:
"""Create an AuthRoot backed by a mock JSON client."""
Expand Down Expand Up @@ -160,14 +149,10 @@ def test_session_without_embed_falls_back(self):
self.assertTrue(self.users_mock["user-1"].get.called)

def test_model_without_user_field_falls_back(self):
"""An installed campus-suite predating the field still resolves."""
session = campus.model.AuthSession.from_resource(
AUTH_SESSION_RESOURCE
)
assert not hasattr(session, "user"), (
"campus-suite now carries the field; this skew case needs a "
"pre-#879 fixture instead"
)
"""A campus-suite predating the #879 field still resolves: the
getattr fallback tolerates session objects with no `user`
attribute at all (pre-#879 model shape), not just user=None."""
session = SimpleNamespace(user_id="user-1")
user = self.push_session(session)
self.assertEqual(user.email, USER_RESOURCE["email"])
self.assertTrue(self.users_mock["user-1"].get.called)
Expand Down
Loading
Loading