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.

122-feature-image.png
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

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:

  1. 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.
  2. 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:

🐍 Index of the Complete Series.