Skip to content

[BUG] Oauth2 refresh token issue #7

Description

@jonaswerme

Summary

WebClient records a rotated refresh token on itself but never writes it back into the
credential body it posts, so the second refresh replays the original token. Any provider
that rotates refresh tokens can therefore only be refreshed once per process.

Affects: pywrapid.webclient.web.WebClient, verified on 0.3.6.

Detail

_parse_authentication_data updates the client's own token attributes:

Line Code
471 self._set_access_token(auth_response_data["access_token"])
480 self._set_refresh_token(auth_response_data["refresh_token"])

generate_session posts the credential body instead, which is never touched:

Line Code
416-417 if self._credential_body: login_options["data"] = self._credential_body

_credential_body is snapshotted at construction (web.py:352) from
OAuth2Credentials.credential_body (web.py:275), which is itself a plain attribute set
once from auth_data.

The access token path is fine: call() reads self._access_token, which is updated.
Only the credential body goes stale, and only the refresh grant depends on it.

Reproduction

Any OAuth2 provider that rotates refresh tokens. Confirmed against Keycloak 26 with
offline_access, where every refresh returns a different token:

stored refresh token before : <omitted>f6ee60
stored refresh token after  : <omitted>4af705
ROTATED
  1. Build OAuth2Credentials(login_url=<token endpoint>, auth_data={"grant_type": "refresh_token", "client_id": ..., "refresh_token": <stored>})
  2. Let the access token expire so call() triggers generate_session() - succeeds, and
    a new refresh token arrives
  3. Let it expire again - generate_session() posts the original token from
    _credential_body

Whether step 3 fails depends on the provider: Keycloak with reuse detection
(Revoke Refresh Token) rejects it and can invalidate the chain, forcing an interactive
sign-in. Not verified here, because testing reuse risks invalidating a live session.

Impact

A credentials subclass that loads a stored offline token - the natural design, and what
the redirect_uri / token_url parameters on OAuth2Credentials anticipate - cannot
sustain a session past one refresh without reaching into WebClient internals.

Suggested fix

When a new token is parsed, write it back to the credential body if the body carries that
key, so what is posted stays current:

if "refresh_token" in auth_response_data:
    self._set_refresh_token(auth_response_data["refresh_token"])
    if "refresh_token" in self._credential_body:
        self._credential_body["refresh_token"] = auth_response_data["refresh_token"]

Backwards compatible: it only rewrites a key the caller already supplied.

Larger alternative, worth considering for the generic rewrite: read
credentials.credential_body at call time rather than snapshotting it, so credentials can
supply the current value per request. That also gives credentials somewhere to persist a
rotated token, which is the other half consumers need.

Our workaround

auth/session.py overrides generate_session() to build the request body fresh from the
token store on every call, and persists the rotated token afterwards. Once fixed we expect to
load into an OAuth2Credentials subclass instead.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions