In the fourth post of our Python FastAPI learning series, we introduced a bug in the authentication process. In this post, we describe the bug and discuss how to fix it.

🐍 Index of the Complete Series.

120-feature-image.png
Python FastAPI: Fixing a Bug in the Authentication Process

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.8.0 https://github.com/behai-nguyen/fastapi_learning.git

❶ The bug occurs when we log in using the Swagger UI Authorize button. The authentication process is successful, but the following error appears on the Swagger UI screen:

auth error SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data

Please see the screenshot illustration below:


It is expecting a JSON response, but the response defaults to HTML.

To get this functionality to work correctly, the endpoint handler method for the https://0.0.0.0:port/auth/token path should default to return JSON as per the original example. It should only return HTML (the home page) when explicitly requested.

❷ After some consideration, I have decided to use an additional hidden field alongside the username and password fields. If this hidden field is present and its value is text/html, then we return the HTML home page.

Since we have already used the custom header field x-expected-format to check for a JSON response, we will adopt this custom header field as our new hidden field as well.

We will change x-expected-format’s constant name to RESPONSE_FORMAT from FORMAT_HEADER to be more generically appropriate.

❸ We will make changes to only a few files. There are no new files. Below is the list of the updated files.

– Please note, those marked with are updated, and those marked with are new.

/home/behai/fastapi_learning/
.
├── README.md ★
├── src
│   └── fastapi_learning
│       ├── common
│       │   └── consts.py ★
│       ├── controllers
│       │   ├── auth.py ★
│       │   └── __init__.py ★
│       ├── __init__.py ★
│       └── templates
│           └── auth
│               └── login.html ★
└── tests
    └── integration
        ├── test_admin_itgt.py ★
        └── test_auth_itgt.py ★

❹ In this section, we will discuss the code changes.

common/consts.py: We just renamed a constant as previously described.

fastapi_learning/__init__.py: We added the classes Token and TokenData as per the later official tutorials, starting with
OAuth2 with Password (and hashing), Bearer with JWT tokens. In the Token class, we have an additional detail string field for informational purposes. We added them to this package file so that they are accessible by all other modules in the application.

controllers/__init__.py: We added a new method async def html_req(request: Request) as discussed previously. Note that we check both the request headers and the field list for x-expected-format, with request headers having higher precedence.

controllers/auth.py: We made the following refactorings to async def login(...):

Annotated the return value as Union[Token, None].

The sub-method async def bad_login(...) refactored to:

    async def bad_login(op_status: ResultStatus):
        ...

        if await html_req(request):
            return RedirectResponse(url=f"{router.url_path_for('login_page')}?state={op_status.code}", 
                                    status_code=status.HTTP_303_SEE_OTHER)        
        else:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 
                                detail=message)

Previously, it was:

    def bad_login(op_status: ResultStatus):
        ...

        if json_req(request):
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 
                                detail=message)
        else:
            return RedirectResponse(url=f"{router.url_path_for('login_page')}?state={op_status.code}", 
                                    status_code=status.HTTP_303_SEE_OTHER)

Fixed the two return statements to:

        return RedirectResponse(url=router.url_path_for('home_page'), status_code=status.HTTP_303_SEE_OTHER) \
            if await html_req(request) else \
                Token(access_token=request.session["access_token"], token_type="bearer", detail=LOGGED_IN_SESSION_MSG)
...				
    return RedirectResponse(url=router.url_path_for('home_page'), status_code=status.HTTP_303_SEE_OTHER) \
        if await html_req(request) else Token(access_token=user_username, token_type="bearer", detail="")

The default response is always JSON. It will only return the HTML home page if explicitly requested. In the previous revisions, they were:

        return {"detail": LOGGED_IN_SESSION_MSG} if json_req(request) \
            else RedirectResponse(url=router.url_path_for('home_page'), status_code=status.HTTP_303_SEE_OTHER)
...
    return {"access_token": user_username, "token_type": "bearer"} \
        if json_req(request) \
        else RedirectResponse(url=router.url_path_for('home_page'), status_code=status.HTTP_303_SEE_OTHER)

templates/auth/login.html: We added the hidden field as previously described.

tests/integration/test_auth_itgt.py</a>: We added the hidden field to the login data in the following methods:

  • test_integration_valid_login_html
  • test_integration_valid_login_twice
  • test_integration_login_bad_email_html
  • test_integration_invalid_username_login_html
  • test_integration_invalid_password_login_html

tests/integration/test_admin_itgt.py</a>: We updated the constant name to RESPONSE_FORMAT. No other changes.

❺ The screenshots below show that the bug has been fixed:


❻ Thank you for reading. Stay safe, as always.

✿✿✿

Feature image source:

🐍 Index of the Complete Series.