While experimenting with some CLI clients for the server implemented in this Python FastAPI learning series, I found two similar bugs in the server: both were related to Redis session entries not being cleaned up.

The first bug involves some temporary redirection entries that do not get removed after the requests are completed. The second, more significant bug, is that the logout process does not clean up the session entry if the incoming request has only the access token and no session cookies.

We address both of these bugs in this post, with most of the focus on the second one.

🐍 Index of the Complete Series.

131-feature-image.png
Python FastAPI: Bug Fixing the Logout Process and Redis Session Cleanup

The code requires Python 3.12.4. Please refer to the following discussion on how to upgrade to Python 3.12.4.

πŸš€ Please note, complete code for this post can be downloaded from GitHub with:

git clone -b v0.14.0 https://github.com/behai-nguyen/fastapi_learning.git

❢ Problem Description and Proposed Solution

The above problems are in the current version, which is v0.13.0, available for cloning with the following command:

git clone -b v0.13.0 https://github.com/behai-nguyen/fastapi_learning.git

There are two problems:

β“΅ Temporary entries that store redirection data between requests do not get removed from Redis storage. This was simple to resolveβ€”just remove the entries after retrieval. We will not go into this any further.

β“Ά Logout does not work with the access token, leaving the Redis session entry behind after logging out.

I understood this issue as early as in the third post:

Subsequent requests from this same authenticated session should include the session UUID cookie as discussed. The session middleware then uses this UUID to load the actual session content from the Redis store, making the access_token available in the incoming request’s request.session property.

Logout request must include the session UUID cookie.

When used as an application server, browsers automatically include this cookie. When used as an API-like server or a service, we must manually include this cookie in the request. Due to a massive oversight, I forgot this issue until it was too late. Let’s illustrate the problem with the following script:

import urllib3

COOKIE: str = None
ACCESS_TOKEN: str = None

def login(http: urllib3.PoolManager, username: str, password: str):

    global COOKIE
    global ACCESS_TOKEN

    # Assuming always succeeds.
    resp = http.request(
        "POST",
        "https://localhost:5000/api/login",
        fields={"username": username, "password": password}
    )

    status = resp.json()

    """
    An example of server response cookie resp.headers['Set-Cookie']:
        session=4441b57be81e7f8ec37b40652b0a8039; path=/; httponly; samesite=lax

    while resp.info().get_all('Set-Cookie') is a list:
        ['session=4441b57be81e7f8ec37b40652b0a8039; path=/; httponly; samesite=lax']
    """

    COOKIE = resp.headers['Set-Cookie']
    ACCESS_TOKEN = status['data']['access_token']

def logout_01(http: urllib3.PoolManager, response_cookie: str):
    """
    This logout request deletes the Redis session entry.
    """

    http.request(
        "POST",
        "https://localhost:5000/auth/logout",
        headers={
            "Cookie": response_cookie,
            "x-expected-format": "application/json",
        }
    )

def logout_02(http: urllib3.PoolManager, access_token: str):
    """
    This logout request DOES NOT delete the Redis session entry.
    """

    http.request(
        "POST",
        "https://localhost:5000/auth/logout",
        headers={
            "Authorization": f"Bearer {access_token}",
            "x-expected-format": "application/json",
        }
    )

urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE', assert_hostname=False)

login(http, 'behai_nguyen@hotmail.com', 'password')

print(f"COOKIE: {COOKIE}")
print(f"ACCESS_TOKEN: {ACCESS_TOKEN}")

logout_01(http, COOKIE)
# logout_02(http, ACCESS_TOKEN)

For more information on urllib3 and SSL/HTTPS, please see the following article: The Python urllib3 HTTP Library and SSL/HTTPS for localhost.

Let’s run the above script as follows:

  1. Call login(...), then logout_01(http, COOKIE): Redis Insight shows that the session entry gets removed, leaving nothing behind.

    πŸ’₯ At the the server-side, the cookie identifies the access token. This makes it unnecessary for the client to remember the access token; they only need to remember the cookie.
  2. Call login(...), then logout_02(http, ACCESS_TOKEN): The session entry did not get removed. In addition, an additional entry with a different session UUID was created. This entry stores the redirection data, which also gets fixed in this post: delete the entry when the data gets retrieved.

β“· The Proposed Solution for the Access Token Problem

πŸ‘‰ The proposed solution is to include the session UUID in the access token payload.

All other endpoints that expect the access token will work as they currently do. The only endpoint that needs refactoring is the logout endpoint: If the request includes the session UUID, then use it to manage the Redis session entry. Otherwise, decode the access token, ignoring expiration and any other errors, then use the session UUID included in the access token payload to manage the Redis session entry. This proposed solution also ensures that, when used as an application server, browser cookies work as they normally do.

πŸ’₯ Please note ahead that this proposed solution raises some more issues, which I haven’t found elegant solutions to. I settled for some workarounds, which are discussed in a a later section.

❷ No new files were added. The current structure of the project is outlined below.

– Please note, those marked with β˜… are updated, and those marked with β˜† are new.

/home/behai/fastapi_learning/
.
β”œβ”€β”€ cert
β”‚Β Β  β”œβ”€β”€ cert.pem
β”‚Β Β  └── key.pem
β”œβ”€β”€ .env
β”œβ”€β”€ logger_config.yaml
β”œβ”€β”€ pyproject.toml
β”œβ”€β”€ pytest.ini β˜…
β”œβ”€β”€ README.md β˜…
β”œβ”€β”€ src
β”‚Β Β  └── fastapi_learning
β”‚Β Β      β”œβ”€β”€ businesses
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ app_business.py
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ base_business.py
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ base_validation.py
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ employees_mgr.py
β”‚Β Β      β”‚Β Β  └── employees_validation.py
β”‚Β Β      β”œβ”€β”€ common
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ consts.py
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ jwt_utils.py β˜…
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ queue_logging.py
β”‚Β Β      β”‚Β Β  └── scope_utils.py
β”‚Β Β      β”œβ”€β”€ controllers
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ admin.py β˜…
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ auth.py β˜…
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ employees_admin.py β˜…
β”‚Β Β      β”‚Β Β  β”œβ”€β”€ __init__.py β˜…
β”‚Β Β      β”‚Β Β  └── required_login.py β˜…
β”‚Β Β      β”œβ”€β”€ __init__.py β˜…
β”‚Β Β      β”œβ”€β”€ models
β”‚Β Β      β”‚Β Β  └── employees.py
β”‚Β Β      β”œβ”€β”€ static
β”‚Β Β      β”‚Β Β  └── js
β”‚Β Β      β”‚Β Β      └── application.js
β”‚Β Β      └── templates
β”‚Β Β          β”œβ”€β”€ admin
β”‚Β Β          β”‚Β Β  └── me.html
β”‚Β Β          β”œβ”€β”€ auth
β”‚Β Β          β”‚Β Β  β”œβ”€β”€ home.html
β”‚Β Β          β”‚Β Β  └── login.html
β”‚Β Β          β”œβ”€β”€ base.html
β”‚Β Β          └── emp
β”‚Β Β              β”œβ”€β”€ insert.html
β”‚Β Β              β”œβ”€β”€ search.html
β”‚Β Β              β”œβ”€β”€ search_result.html
β”‚Β Β              └── update.html
└── tests
    β”œβ”€β”€ business
    β”‚Β Β  β”œβ”€β”€ test_employees_mgr.py
    β”‚Β Β  β”œβ”€β”€ test_employees_validation.py
    β”‚Β Β  └── test_scope_utils.py
    β”œβ”€β”€ conftest.py
    β”œβ”€β”€ __init__.py
    β”œβ”€β”€ integration
    β”‚Β Β  β”œβ”€β”€ test_admin_itgt.py
    β”‚Β Β  β”œβ”€β”€ test_api_itgt.py
    β”‚Β Β  β”œβ”€β”€ test_auth_itgt.py
    β”‚Β Β  β”œβ”€β”€ test_employees_itgt.py
    β”‚Β Β  β”œβ”€β”€ test_expired_jwt.py
    β”‚Β Β  β”œβ”€β”€ test_scope_permission_itgt.py
    β”‚Β Β  └── test_scope_ui_itgt.py
    β”œβ”€β”€ README.md
    └── unit
        └── test_employees.py

❸ Code Refactorings

β“΅ Accessing the session UUID to Include in the Access Token Payload

I have looked through the starsessions library documentation and code, and I could not find a way to generate the session UUID in advance of access token creation. In the current version (v0.13.0), after calling set_access_token(request, access_token), the session UUID is still not accessible, at least from my understanding. It is first available after redirecting to /auth/token, i.e., the home_page(...) endpoint handler method.

As we also redirect to the home_page(...) under different scenarios, we cannot create the access token there. Instead, we redirect to a β€œprivate” endpoint, create and set the access token in this endpoint handler method, and then redirect to the /auth/token endpoint as before.

β“Ά Refactoring the login process

Within the login(...) endpoint handler method, these lines are the refactored code. The FIXME markers indicate workaround code for which I hope to find a better solution later on. Please note the following:

303
304
305
306
307
    # 
    # FIXME: HACK! This causes the 'session' cookie created, so that 
    #     async def __internal(request: Request): can access the session Id.
    #
    request.session['x-email'] = op_status.data[0]['email']

Without setting an entry in request.session, the session UUID cookie would not be created.

322
323
324
325
326
327
    response.set_cookie(key=RESPONSE_FORMAT, value=response_format, httponly=True)
    response.set_cookie(key='x-email', value=op_status.data[0]['email'], httponly=True)
    response.set_cookie(key='x-emp-no', value=str(op_status.data[0]['emp_no']), httponly=True)
    response.set_cookie(key='x-scopes', value='^'.join(op_status.data.scopes), httponly=True)

    return response

We need these pieces of information in the __internal(...) endpoint handler method. In this method, we extract the information from the cookies to create the access token and set it to the session storage. We remove all temporary entries set in cookies and session storage. Then, finally, we redirect to the home page endpoint handler method as before.

I don’t particularly like this workaround, but for the time being, this is the best I could do. I have tried several other approaches and have not succeeded in getting them to work.

β“· Rewrote and Bug-Fixed the logout(...) Endpoint Handler Method

Again, please note the FIXME markers. Please note the following:

● Added the parameter token: Annotated[str, Depends(oauth2_scheme)]: This ensures that this endpoint method gets the access token.

● First, it looks into the request cookies to extract the session UUID. If there are no cookies, it extracts the session UUID from the access token.

● Then it uses Redis directly to remove the session entry:

366
367
368
    response.set_cookie(key=RESPONSE_FORMAT, value=res        # Does this session Id exist, currently?
        auth_session = session_id in redis_server.scan_iter(session_id)
        redis_server.delete(session_id)

The redis_server instance is:

41
42
redis_server = redis.Redis(host=os.environ.get("REDIS_URL", "redis://localhost").split('//')[1], 
                           decode_responses=True)

This is a bit drastic. I do not like it. But I do not know of another alternative.

I have written middleware to inject starsessions’s RedisStore into the request and use the RedisStore instance to access Redis. However, in the latest version, they removed the exists(...) method, which I need.

These changes take care of cleaning up the Redis session storage when logging out using access tokens.

β“Έ Redecorate Endpoint Methods

I have just learned that endpoint methods can be decorated with multiple different path decorators. πŸ˜‚

In the admin.py module, we removed the method read_users_me_api(...), and decorated the method read_users_me(...) with @api_router.get("/me").

Similarly, in the auth.py module, we removed the method login_api(...), and moved the decorator @api_router.post("/login") to the login(...) method.

Finally, we created a new /api/logout route with the following decorator @api_router.post("/logout", response_class=HTMLResponse) on the logout(...) endpoint handler method.

❹ On Tests

All of the 84 tests remain unchanged, and they all pass with this new implementation. I intentionally did not write any new tests; I just ensured the existing tests work as they are. I might write some more later. Importantly, after running all tests, the Redis server should show no entries.

❺ Test the Refactored Code with a CLI Test Script

Let’s see how the illustrative script previously demonstrated would work with the refactored code. But first, recall from the login process code refactoring that we redirect with some cookies set. The urllib3 library does not have cookies enabled, and the server will not handle cookies correctly. We need to enable cookies ourselves, which I have not been able to figure out how to do. The Requests library, which uses urllib3, handles cookies out of the box.

So we need to modify the original script a little: The two logout methods remain unchanged, and the login(...) method uses the Requests library. Please note the header setting headers={'x-referer': 'desktopclient'}, which is used in the __internal(...) endpoint handler method as discussed.

import urllib3
import requests

COOKIE: str = None
ACCESS_TOKEN: str = None

def login(http: requests.Session, username: str, password: str):

    global COOKIE
    global ACCESS_TOKEN

    # Assuming always succeeds.
    resp = http.post(
        "https://localhost:5000/api/login",
        headers={'x-referer': 'desktopclient'},
        data={"username": username, "password": password},
        verify=False
    )

    status = resp.json()

    COOKIE = f"session={resp.cookies.get('session')}; path=/; httponly; samesite=lax"
    ACCESS_TOKEN = status['data']['access_token']

def logout_01(http: urllib3.PoolManager, response_cookie: str):
    """
    This logout request deletes the Redis session entry.
    """

    http.request(
        "POST",
        "https://localhost:5000/auth/logout",
        headers={
            "Cookie": response_cookie,
            "x-expected-format": "application/json",
        }
    )

def logout_02(http: urllib3.PoolManager, access_token: str):
    """
    This logout request DOES NOT delete the Redis session entry.
    """

    http.request(
        "POST",
        "https://localhost:5000/auth/logout",
        headers={
            "Authorization": f"Bearer {access_token}",
            "x-expected-format": "application/json",
        }
    )

# Suppress only the single warning from urllib3.
urllib3.disable_warnings(category=urllib3.exceptions.InsecureRequestWarning)
session = requests.Session()

urllib3.disable_warnings()
http = urllib3.PoolManager(cert_reqs='CERT_NONE', assert_hostname=False)

login(session, 'behai_nguyen@hotmail.com', 'password')

print(f"COOKIE: {COOKIE}")
print(f"ACCESS_TOKEN: {ACCESS_TOKEN}")

logout_01(http, COOKIE)
# logout_02(http, ACCESS_TOKEN)

Please run it as previously described, but this time, both of the logout methods should remove all entries from the Redis server storage.

❻ I am not sure how much I have understood starsessions,
even though I have spent time looking at its code. Ideally, users should be able to dictate when to create the session UUID and thus the session entry. So far, this does not seem to be the case with this library.

Thank you for reading. I hope you find the information in this post useful. Stay safe, as always.

✿✿✿

Feature image source:

🐍 Index of the Complete Series.