lepture / authlib

The ultimate Python library in building OAuth, OpenID Connect clients and servers. JWS,JWE,JWK,JWA,JWT included.
https://authlib.org/
BSD 3-Clause "New" or "Revised" License
4.55k stars 452 forks source link

Refreshing the token will not updated refresh_token when using authlib.integrations.starlette_client #548

Open kwibus opened 1 year ago

kwibus commented 1 year ago

Describe the bug

When you use authlib.integrations.starlette_client. And you make a request. the authlib.integrations.starlette_client will use the refresh token if you acces token is expired. But if authorization server gives you a new refresh token. You should start using the new refresh token.

authlib.integrations.starlette_client will do that for this first request with the expired token. But because authlib.integrations.starlette_client does not update the token you used in place. your second request will use the same expired token as the first request.

So it will try to refresh again. this seems unnecessary But this second refresh will also use the old refresh token, not the one it got from the first one. The (oauth rfc)[https://datatracker.ietf.org/doc/html/rfc6749#section-6]

The authorization server MAY issue a new refresh token, in which case the client MUST discard the old refresh token and replace it with the new refresh token. The authorization server MAY revoke the old refresh token after issuing a new refresh token to the client. If a new refresh token is issued, the refresh token scope MUST be identical to that of the refresh token included by the client in the request.

And some server will not allow you to do this.

Error Stacks

INFO:     Started server process [96666]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     None:0 - "GET /login HTTP/1.1" 302 Found
INFO:     None:0 - "GET /redirect?code=stampNL001.7haR%21IAAAANspwF7PIOViHpzH2INQGXsnRJqMTLZJ-vaCjb5BmCg08QEAAAGaJPmMw8RTPGtsw2KPfKyolmso4R_qaX7i2gFDWbDkCHxBtb7cdUvHP5TPzTbuPsDAnWtlkKmIxfvXoiryjyp98yDVyEPUD5Ow0qAP0sfNzPMTXKTMWbxlvGk_d557omRZo7_L97_7QJxzQpeA6ukSc6529402st5HXnNsDBivsSq5c_jigHG_wRZVmnElPNHGqTZvewcswAcRNSIvYjxEmKwxtzDFOOChBpAwLJa51hUfO6Q0qrA2779knQDPlV0VUjUE5tRoDQvplbSi1onoksdc_qZMNfoIgLOBLq4BCh2JXHpBRbikjmE-6kd133R-PiQ12s9jLLVfu5R7hS1FbEUG0x9jvx1JyDJ9YhoMTW6hiXTPtx62_wmWVslGELG8AlM1voB1pgF4XYiSsFA5PWVVwFaQWoGC_BN6KDVKvZuXANTnffIUc540L3WJz2NPs4-IW56BmZElCBjqwvUSjrTh4muNnJC2oleMF1Pp1d3nE_JcsQsRrZpH8eWK54L3exupV_b81oNvR-7W98GJaGUeljt0zA4LGS4opESsgpGUL5tk3g9Eb4i4Kd8a5Ks0xOZs9AwNeiFK-o5202ctrM0RBo4IrynKWYwbaMXa8auByTq0fZ9FecJZKoj2o7csXo4slb_B46v-Z02JYbNe&state=Jc9pxFjTmFlNqibicRHZ1MXOb8zeAX HTTP/1.1" 200 OK
2023-06-05 15:10:57
2023-06-05 15:10:57
INFO:     None:0 - "GET /user HTTP/1.1" 200 OK
2023-06-05 15:10:57
2023-06-05 15:10:57
INFO:     None:0 - "GET /user HTTP/1.1" 200 OK
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "/home/rens/.local/lib/python3.11/site-packages/uvicorn/protocols/http/h11_impl.py", line 429, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 78, in __call__
    return await self.app(scope, receive, send)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/fastapi/applications.py", line 276, in __call__
    await super().__call__(scope, receive, send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__
    raise exc
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__
    await self.app(scope, receive, _send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/middleware/sessions.py", line 86, in __call__
    await self.app(scope, receive, send_wrapper)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__
    raise exc
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__
    await self.app(scope, receive, sender)
  File "/home/rens/.local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 21, in __call__
    raise e
  File "/home/rens/.local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
    await self.app(scope, receive, send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__
    await route.handle(scope, receive, send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle
    await self.app(scope, receive, send)
  File "/home/rens/.local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app
    response = await func(request)
               ^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/fastapi/routing.py", line 237, in app
    raw_response = await run_endpoint_function(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/fastapi/routing.py", line 163, in run_endpoint_function
    return await dependant.call(**values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/werk/exact/example-oauth2/main.py", line 62, in get_user
    response2 = await oauth.exact.get('v1/current/Me',
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/integrations/base_client/async_app.py", line 86, in request
    return await _http_request(self, session, method, url, token, kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/integrations/base_client/async_app.py", line 144, in _http_request
    return await session.request(method, url, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/integrations/httpx_client/oauth2_client.py", line 85, in request
    await self.ensure_active_token(self.token)
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/integrations/httpx_client/oauth2_client.py", line 112, in ensure_active_token
    await self.refresh_token(url, refresh_token=refresh_token)
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/integrations/httpx_client/oauth2_client.py", line 148, in _refresh_token
    token = self.parse_response_token(resp)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/rens/.local/lib/python3.11/site-packages/authlib/oauth2/client.py", line 340, in parse_response_token
    raise self.oauth_error_class(
authlib.integrations.base_client.errors.OAuthError: unauthorized_client: Old refresh token used.

To Reproduce

I talk to exact-online api that does not allow reuse of refresh tokens. This is but unusual, but confirming to Oauth 2 rfc.

This the code i used to reproduce this error. (full code can be found here: main.py.txt)

@app.get("/user")
async def get_user(request: Request): # W: Missing function or method docstring                                                                                 
    token = request.session["token"]                                                                                                                           

    expires_at = datetime.fromtimestamp(int(token["expires_at"]))                                                                                                     
    print(expires_at)                                                                                                                                                  
    response = await oauth.exact.get('v1/current/Me', #
                                     token=token,                                                                                                
                                     headers={'Accept': 'application/json'})                                                      

    expires_at = datetime.fromtimestamp(int(token["expires_at"]))                                                               
    print(expires_at)                                                                                                                     
    response2 = await oauth.exact.get('v1/current/Me',                                                                              
                                     token=token, 
                                     headers={'Accept': 'application/json'}) #
    return response2.json()

I know that part of the problem is that I use the same token twice. But there is no way to get the new token. if the refresh is done. Or do i miss something?

Expected behavior

I expect that either:

Additional context

Hope I made it clear what te problem is. I understand its not easy for other to test this with exact online api. But i assume it works the same with others. But they will problem not complain about reusing a refresh token.

Let me know if i can help with something?

jamesearl commented 3 months ago

I've encountered this as well. This issue is not relegated to starlette or any particular client/provider.

My current workaround is to reload the token from storage after the refresh has happened, but in many cases that leads to some gnarly code. Like, rereading the token from the db before every.single.api call. Not great for performance when I need to make hundreds or thousands of calls.

AdamGold commented 2 months ago

Anything new here?