Python FastAPI: Implementing OAuth2 Scopes Part 01
In this part of our Python FastAPI learning series, we implement OAuth2 scopes. Our implementation is based on the advanced official tutorial on OAuth2 scopes, with some variations of our own.
π Index of the Complete Series.
Python FastAPI: Implementing OAuth2 Scopes Part 01 |
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.10.0 https://github.com/behai-nguyen/fastapi_learning.git
Table Of Contents
- The Objective of This Post
- Users and Scopes Assignment
- Project Layout
- Implementation Refactorings
- Tests
- Testing Scope Implementation Using The Application UI
- Concluding Remarks
The Objective of This Post
As stated, in this post, we discuss the implementation of OAuth2 scopes for our Python FastAPI learning application.
The implementation is based on the advanced official tutorial on OAuth2 scopes. As we will see in later sections, our implementation of scopes is slightly different from that of the tutorial, but it is still essentially based on this advanced tutorial.
OAuth2 scopes are only limited to the /admin/me
path and hence automatically propagate to the /api/me
path.
For the list of currently available URLs, please see this section from an earlier post.
Scopes are enforced at endpoint handler methods as per the tutorial, but not yet at the UI level. That is, checking for assigned scopes before generating the UIs that trigger requests to the underlying endpoint handlers.
Please note that a scope
implicitly implies permission
. We will use the two terms interchangeably throughout this post.
Users and Scopes Assignment
We donβt provide users with a list of scopes to choose from. The application determines which scopes are available to a user after they successfully log in. This approach is based on the following recommendation:
β¦ But in your application, for security, you should make sure you only add the scopes that the user is actually able to have, or the ones you have predefined.
β See the following official Danger alert note on JWT token with scopes.
π₯ Please take note of the following two implementation-specific approaches:
-
We are implementing a mock user-scope assignment. That is, we hardcode a
list of users with specific scope(s). The rest of the users who are not
in this list are assigned the default scope of
user:read
. - Endpoint handler methods might require more than one scope. The requesting user must have all those scopes; otherwise, the request is rejected.
Project Layout
The full updated 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
βββ main.py
βββ 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 β
βΒ Β βΒ Β βββ __init__.py
βΒ Β βββ __init__.py β
βΒ Β βββ models
βΒ Β βΒ Β βββ employees.py
βΒ Β βββ static
βΒ Β βΒ Β βββ styles.css
βΒ Β βββ templates
βΒ Β βββ admin
βΒ Β βΒ Β βββ me.html
βΒ Β βββ auth
βΒ Β βΒ Β βββ home.html
βΒ Β βΒ Β βββ login.html
βΒ Β βββ base.html
βββ tests
βββ business
βΒ Β βββ test_employees_mgr.py
βΒ Β βββ test_scope_utils.py β
βββ conftest.py
βββ __init__.py
βββ integration
βΒ Β βββ test_admin_itgt.py
βΒ Β βββ test_api_itgt.py
βΒ Β βββ test_auth_itgt.py
βΒ Β βββ test_expired_jwt.py
βΒ Β βββ test_scope_permission_itgt.py β
βββ README.md
βββ unit
βββ test_employees.py
Implementation Refactorings
In this section, we discuss the code changes.
src/fastapi_learning/__init__.py
In a previous revision, we defined APP_SCOPES
. This is the list of all scopes. The meaning of each scope might evolve as we add more functionalities to the application.
In this revision, we add an additional definition: APP_SCOPE_DEPENDENCIES
. Letβs have a look at this definition.
Certain scopes imply having some other scopes. The scope admin:read
implies also having the user:read
scope. That is, for endpoint handlers that require the user:read
scope, if the requesting user only has admin:read
, the request would be deemed to have enough permission as admin:read
includes user:read
.
Likewise, the scope super:*
includes all other scopes.
src/fastapi_learning/businesses/employees_mgr.py
There are the following changes to the businesses/employees_mgr.py
module:
β΅ A mock user list as previously described:
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# Proper implementation might turn this into a database table.
MOCK_USER_SCOPES = [
{
'user_name': '*',
'scopes': ['user:read']
},
{
'user_name': 'moss.shanbhogue.10045@gmail.com',
'scopes': []
},
{
'user_name': 'behai_nguyen@hotmail.com',
'scopes': ['user:read', 'user:write']
},
{
'user_name': 'kyoichi.maliniak.10005@gmail.com',
'scopes': ['admin:read', 'admin:write']
},
{
'user_name': 'mary.sluis.10011@gmail.com',
'scopes': ['super:*']
}
]
Letβs explain the above list:
-
User
*
: This entry denotes the scope(s) assigned to all users who are not in this list. -
User
moss.shanbhogue.10045@gmail.com
: This user is effectively banned. They can only log in and log out, and that is about it. -
User
behai_nguyen@hotmail.com
: This user can only read and update their own information. The precise meaning of βown informationβ is still evolving. At this point, it is the user information. -
User
kyoichi.maliniak.10005@gmail.com
: This is an admin user. They can read and update their own as well as other usersβ information. When more functionalities are added, this user might have access to those new functionalities as well. -
User
mary.sluis.10011@gmail.com
: This is a super user. Super users should have access to all applicationβs functionalities.
Please note that user and scope/permission testing are based around these mock users and scopes assignment.
βΆ A new method which retrieves usersβ scopes using the MOCK_USER_SCOPES
:
66
67
68
69
70
def mock_get_user_scopes(email: str) -> list:
res = [item for item in MOCK_USER_SCOPES if item['user_name'] == email]
return res[0]['scopes'] if len(res) > 0 else \
[item for item in MOCK_USER_SCOPES if item['user_name'] == '*'][0]['scopes']
The code is simple: If the user β identified by email
β does not match any user in the mock list, then use the user_name
of *
to retrieve the scopes.
π₯ Proper implementation would have APP_SCOPES, APP_SCOPE_DEPENDENCIES, and MOCK_USER_SCOPES in a database, and this method would just retrieve the user scope list from the database.
β· And finally, in the login(...)
method: After successfully logging a user in, we proceed to retrieve the user-assigned scopes and add the scope list to the return data as:
163
status = status.add_data(mock_get_user_scopes(email), 'scopes')
Below is an example of a login return dictionary which includes the logged-in user assigned scope list:
{
"status": {
"code": 200,
"text": "Data has been retrieved successfully."
},
"data": {
"items": [
{
"emp_no": 600001,
"email": "behai_nguyen@hotmail.com",
"password": "$argon2id$v=19$m=16,t=2,p=1$b2szcWQ4a0tlTkFydUdOaw$7LX7WCYbItEMEwvH3yUxPA",
"birth_date": "09/12/1978",
"first_name": "Be Hai",
"last_name": "Nguyen",
"gender": "M",
"hire_date": "08/10/2021"
}
],
"scopes": [
"user:read",
"user:write"
]
}
}
src/fastapi_learning/common/jwt_utils.py
In the jwt_utils.py
module, there is a single code update in the method decode_access_token(...)
:
44
return TokenData(user_name=username, scopes=payload.get("scopes", []))
That is, we extract the scope list from the returned logged-in data, and set this list to the scopes
field of the returned TokenData
. The login and scope list have been discussed in this section.
src/fastapi_learning/common/scope_utils.py
This new common/scope_utils.py
module is web framework independent. It could be taken out and used in other applications that use the same scope/permission approach.
There is only a single method, has_required_permissions(...)
. This method implements the permissions checking which we have previously outlined:
Endpoint handler methods might require more than one scope. The requesting user must have all those scopes; otherwise, the request is rejected.
The implementation of the method itself has sufficient inline comments explaining the code. It is also fairly short and should be self-explanatory.
It is further discussed in a later section on testing.
src/fastapi_learning/controllers/auth.py
In the controllers/auth.py
module, we update the login(...)
method to include the scope list in the access token:
179
180
access_token = create_access_token(data={'sub': op_status.data[0]['email'],
'scopes': op_status.data.scopes})
The new field 'scopes': op_status.data.scopes
is added to the data
dictionary parameter. Please refer to a previous discussion for detailed information on how the scope list is constructed.
src/fastapi_learning/controllers/admin.py
In the existing controllers/admin.py
module, we make two changes to the helper method get_current_user(...)
:
β΅ Add the new parameter security_scopes: SecurityScopes
, which contains the required scopes to run this operation:
58
59
60
async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)]):
βΆ Call the has_required_permissions(...)
method to ascertain that the requesting user has sufficient permissions:
94
97
98
99
if not has_required_permissions(security_scopes.scopes, token_data.scopes):
logger.debug(INVALID_PERMISSIONS_MSG)
credentials_exception.detail = INVALID_PERMISSIONS_MSG
return credentials_exception
If the scope requirements fail, meaning the requesting user does not have sufficient permissions, then an appropriate exception is returned.
Please note, we could do permission checking prior to identifying the user, but I think checking for the user first, then checking for user permissions is a bit more logical.
And finally, both endpoint handler methods read_users_me(...)
and read_users_me_api(...)
signatures get updated as follows:
101
102
103
104
105
106
@router.get("/me")
async def read_users_me(
request: Request,
current_user: Annotated[LoggedInEmployee,
Security(get_current_user, scopes=["user:read"])]
):
162
163
164
165
166
167
@api_router.get("/me")
async def read_users_me_api(
request: Request,
current_user: Annotated[LoggedInEmployee,
Security(get_current_user, scopes=["user:read"])]
):
That is, for the parameter current_user
, Security(get_current_user, scopes=["user:read"])
replaces Depends(get_current_active_user)
.
Tests
We have implemented two new test modules for testing the new scope/permission feature.
β΅ The new test module test_scope_utils.py
.
Tests in this module ensure that the method has_required_permissions(...)
in the module common/scope_utils.py
works as intended.
βΆ The new test module test_scope_permission_itgt.py
.
Tests in this module ensure that scopes assigned to users work as intended. That is, users with sufficient scope assignments should be able to access the requested URL paths. Currently, we have two such URLs, which are /admin/me
and /api/me
. Please see an earlier discussion in the introduction of this post.
β· All existing tests should still work without needing any refactoring. Please see the screenshot below:
For documentation on the exceptions raised at the end of tests, please see this document.
Testing Scope Implementation Using The Application UI
Letβs see how scopes work using the application UI. Log in using
moss.shanbhogue.10045@gmail.com
and password
.
Recall that this user
has no assigned scope
and is effectively banned. This user can only log in and log out.
The screenshot below shows the access token:
The screenshot below shows that the access token contains an empty scope list:
When we click on the My Info
button, the request gets rejected with an appropriate message:
Similarly, when we click on the My Info as JSON
button, the request is also rejected with a similar message:
Finally, accessing the same endpoint using the same access token in Postman:
Concluding Remarks
This is the first part of our exploration of OAuth2 scopes.
Next, I would like to have an option to display or hide UI elements based on usersβ scope availability. For example, for a user such as moss.shanbhogue.10045@gmail.com
, who has no scope, the application should be able to decide whether or not to display the My Info
and My Info as JSON
buttons, since the underlying endpoint handler method rejects the requests anyway.
Once this new feature has been implemented, it will automatically apply to any new functionalities we add to the application.
Thank you for reading. I hope you find the information in this post useful. Stay safe, as always.
βΏβΏβΏ
Feature image source:
- https://www.omgubuntu.co.uk/2022/09/ubuntu-2210-kinetic-kudu-default-wallpaper
- https://in.pinterest.com/pin/337277459600111737/
- https://www.python.org/downloads/release/python-3124/
- https://fastapi.tiangolo.com/
- https://1000logos.net/download-image/
- https://www.logo.wine/logo/MySQL
- https://icon-icons.com/download/170836/PNG/512/
- https://www.stickpng.com/img/icons-logos-emojis/tech-companies/mariadb-full-logo