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
- Build
OAuth2Credentials(login_url=<token endpoint>, auth_data={"grant_type": "refresh_token", "client_id": ..., "refresh_token": <stored>})
- Let the access token expire so
call() triggers generate_session() - succeeds, and
a new refresh token arrives
- 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.
Summary
WebClientrecords a rotated refresh token on itself but never writes it back into thecredential 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_dataupdates the client's own token attributes:self._set_access_token(auth_response_data["access_token"])self._set_refresh_token(auth_response_data["refresh_token"])generate_sessionposts the credential body instead, which is never touched:if self._credential_body: login_options["data"] = self._credential_body_credential_bodyis snapshotted at construction (web.py:352) fromOAuth2Credentials.credential_body(web.py:275), which is itself a plain attribute setonce from
auth_data.The access token path is fine:
call()readsself._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:OAuth2Credentials(login_url=<token endpoint>, auth_data={"grant_type": "refresh_token", "client_id": ..., "refresh_token": <stored>})call()triggersgenerate_session()- succeeds, anda new refresh token arrives
generate_session()posts the original token from_credential_bodyWhether step 3 fails depends on the provider: Keycloak with reuse detection
(
Revoke Refresh Token) rejects it and can invalidate the chain, forcing an interactivesign-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_urlparameters onOAuth2Credentialsanticipate - cannotsustain a session past one refresh without reaching into
WebClientinternals.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:
Backwards compatible: it only rewrites a key the caller already supplied.
Larger alternative, worth considering for the generic rewrite: read
credentials.credential_bodyat call time rather than snapshotting it, so credentials cansupply 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.pyoverridesgenerate_session()to build the request body fresh from thetoken store on every call, and persists the rotated token afterwards. Once fixed we expect to
load into an
OAuth2Credentialssubclass instead.