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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,20 @@ cookie transport only, a request presenting the same refresh cookie within 5 sec
the same rotated session, so several tabs with one expired session all succeed. A bearer client
presenting a spent refresh token always reaches the auth API.

Only a successful rotation is shared for those 5 seconds, and only a refresh the auth API refuses
ends a browser session. In cookie transport, both the silent refresh on an auth route and
`POST /auth/refresh` clear the session cookies only when the auth API answers the refresh with 401
(silent refresh answers 401 `Refresh failed`, `POST /auth/refresh` answers 401
`invalid_refresh_token`) or when the refresh cookie cannot be read. Any other failure keeps the
cookies, so the next request tries again:

| The refresh | The answer |
| --- | --- |
| does not reach the auth API | 502 `upstream_unavailable` |
| gets another non-2xx answer (a 503 during maintenance, a 429) | that status and body |
| gets a 2xx with no session, or one that fails verification | 502 `upstream_unavailable` |
| waits on another request's rotation for it and times out | 502 `upstream_unavailable` |

## The manifest

On its first request the adapter fetches `/.well-known/seamless-adapter.json` from the auth API
Expand Down
51 changes: 37 additions & 14 deletions src/seamless_auth/_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,16 @@ def _error(status: int, code: str) -> _Reply:
return _Reply(status, {"error": code})


@dataclass
class _RefreshFailed:
"""A cookie-transport refresh that did not rotate. ``reply`` is what to answer
while the session stands; None means the session is over: the auth API refused
the refresh token, or the refresh cookie could not be read."""

reason: str
reply: _Reply | None = None


@dataclass
class _Forwarded:
ip: str | None
Expand Down Expand Up @@ -483,8 +493,10 @@ def _credential_for(
if isinstance(refreshed, tuple):
session, cookies = refreshed
return f"Bearer {str_claim(session, 'token')}", cookies
if refreshed != _NO_REFRESH_COOKIE:
log.debug("[seamless-auth] Silent refresh failed: %s", refreshed)
if refreshed is not None:
log.debug("[seamless-auth] Silent refresh failed: %s", refreshed.reason)
if refreshed.reply is not None:
return refreshed.reply
reply = _error(401, "Refresh failed")
reply.clear = [
self._config.access_cookie_name,
Expand Down Expand Up @@ -782,28 +794,40 @@ def _rotate(self, forwarded: _Forwarded, refresh_token: str, service: str) -> Re

def _silent_refresh(
self, headers: Headers, forwarded: _Forwarded
) -> tuple[Claims, list[CookieWrite]] | str:
"""Rotates the session held in the refresh cookie."""
) -> tuple[Claims, list[CookieWrite]] | _RefreshFailed | None:
"""Rotates the session held in the refresh cookie. None when there is no
refresh cookie. Only a 401 from the auth API or an unreadable cookie ends
the session; a failure that may pass (the API unreachable or answering
otherwise, a rotation that cannot be vouched for) keeps it, so the next
request can try again."""
name = self._config.refresh_cookie_name
raw = raw_cookie(headers, name)
if not raw:
return _NO_REFRESH_COOKIE
return None
payload = read_cookie(self._config, headers, name, KIND_REFRESH)
refresh_token = str_claim(payload, "refreshToken")
if not refresh_token:
return "invalid refresh cookie"
return _RefreshFailed("invalid refresh cookie")
service = self._sign_service(str_claim(payload, "sub"), {"refreshToken": refresh_token})

outcome = self._refreshes.share(
f"cookie:{raw}", lambda: self._rotate(forwarded, refresh_token, service), keep=True
)
unavailable = _error(502, "upstream_unavailable")
if outcome.session is None:
return outcome.error or f"refresh answered {outcome.status}"
if outcome.error is not None:
return _RefreshFailed(outcome.error, unavailable)
reason = f"refresh answered {outcome.status}"
if outcome.status == 401:
return _RefreshFailed(reason)
if 200 <= outcome.status < 300:
return _RefreshFailed(reason, unavailable)
return _RefreshFailed(reason, _failure(outcome.status, outcome.body, outcome.is_json))
try:
session_id = self._verify_session(outcome.session, "access")
cookies = session_cookies(self._config, outcome.session, session_id, True)
except (TokenError, ValueError) as err:
return str(err)
return _RefreshFailed(str(err), unavailable)
return outcome.session, cookies

def _refresh_route(self, headers: Headers, forwarded: _Forwarded, bearer: bool) -> _Reply:
Expand All @@ -826,10 +850,12 @@ def _refresh_route(self, headers: Headers, forwarded: _Forwarded, bearer: bool)
return _Reply(200, outcome.session)

refreshed = self._silent_refresh(headers, forwarded)
if refreshed == _NO_REFRESH_COOKIE:
if refreshed is None:
return _error(401, "refresh token required")
if not isinstance(refreshed, tuple):
log.debug("[seamless-auth] Refresh failed: %s", refreshed)
if isinstance(refreshed, _RefreshFailed):
log.debug("[seamless-auth] Refresh failed: %s", refreshed.reason)
if refreshed.reply is not None:
return refreshed.reply
reply = _error(401, "invalid_refresh_token")
reply.clear = [self._config.access_cookie_name, self._config.refresh_cookie_name]
return reply
Expand Down Expand Up @@ -1014,9 +1040,6 @@ def _is_allowed_origin(self, origin: str) -> bool:
)


_NO_REFRESH_COOKIE = "no refresh cookie"


def _check_roles(user: User, required: tuple[str, ...] | None) -> None:
if required is not None and not has_any_role(user, required):
raise InsufficientRole
Expand Down
Loading
Loading