Introduction
I was surprised to see that it's been over two years since my last blog post on Python or FastAPI. Actually, it's been over two years since my last programming article—period.
Therefore, I had the itch to restart and share another Python post. I have already written two articles on the subject:
This post will have some overlap but, instead, will look at how to use Auth0 to authorize users, and how to use the user identification from Auth0 to query a local Neo4j server.
There are some holes in this approach, so this will not be an all-encompassing post, but I'll point out where it's lacking.
Authorization Sequence Diagram
In our web application setup, a client representing the user authenticates using a username and password to the Auth0 server. Then, the client receives a token, which is subsequently sent to our custom API on each request authorizing access to the data. The sequence diagram below shows this process in the first three steps.
Next, the API needs to decode the token from the client before querying the database. If all goes well, the database returns the user to the API, and the API can send data back to the user.
Let's take a closer look at each step with some Python code.
How to Setup FastAPI Authorization with Auth0
There are a couple of ways the user can retrieve a token.
- Auth0 log-in screen as part of the application sign-in flow. Take a look at the quickstarts.
- Auth0 Resource Owner Password Flow
The latter is not recommended, however, it's what I used to test the authorization process because it allows me to use Postman to retrieve the user token and test the token validity.
# The login route is used for retrieving a user token when testing with
# an API client (i.e. Postman) and is not how the user will normally
# authenticate to use the application.
@router.post(
"/",
description=(
"Login user using Auth0 with Username Password Authentication to get access"
" token for API."
),
response_model=Auth0UsernamePasswordCredentials,
)
async def login(
raw_user_credentials: UserAuthentication,
):
username = raw_user_credentials.username
password = raw_user_credentials.password
audience = settings.auth0_audience
try:
resp = get_token.login(
username=username,
password=password,
realm="Username-Password-Authentication",
audience=audience,
)
return resp
except Auth0Error as e:
# TODO: Log error
print(e.message)
raise AuthenticationProviderException
I'll share the code in pieces for this article, but you can take a more comprehensive look if you check out the gist.
This route is triggered with a POST request with the username and password of the user in the body. Then, using the GetToken
class in the auth0-python SDK, the API sends the credentials to Auth0. If it works, the API gets an access token which it can send back to the client.
Decoding Auth0 Token with Python
Now that the client has a token, it's authorized to request data from the API. However, instead of returning data, we're going to take the token, decode it, and then use the decoded information to query our database for the user that sent the request. Finally, we'll return that information to the client.
First, let's look at where the request will enter the API in the router.
@router.get(
"/",
name="User",
description="Get the authenticated client user in database.",
response_model=User,
responses={401: {"model": HTTPException}},
)
async def root(user: User = Depends(active_user)):
return user
Pretty simple! But, we need to pull on the dependency string to see where it leads. The login
function receives the user
argument as a function of the dependency, Depends(active_user)
.
The active_user
function is the first in a series of middleware (dependency) functions that each does its own part in getting us the user data.
def active_user(userInDb: Annotated[UserInDB, Depends(active_user_in_db)]) -> User:
"""Initiates a `User` from a `UserInDB` model"""
# Remove sensitive information
user = User(**userInDb)
return user
active_user
places the UserInDB
Pydantic data model into the User
data model, effectively stripping it of sensitive information.
This function gets the userInDb
argument from the active_user_in_db
function.
def active_user_in_db(
decodePayload: Annotated[JWTPayload, Depends(validate_token)]
) -> User:
"""Find user in database matching Auth0 `sub` parameter in JWT Payload"""
# Create session for a sequence of transactions
with driver.session() as session:
# Value to identify user in database
sub = decodePayload.sub
try:
# Commit user query
userInDb = session.execute_read(get_user, sub=sub)
return userInDb
except Neo4jError as e:
print(e.message)
raise DatabaseError
active_user_in_db
is the step in the process where we take the decoded JWT payload, access the sub
parameter (Auth0 user identification), and then match the value to a value in our Neo4j database.
So, where do we get the decoded JWT payload? Continuing our journey back through the API we see the validate_token
dependency function providing the decodedPayload
argument.
# FastAPI 'Dependency' for parsing Authorization header `Bearer [token]`
token_auth_scheme = HTTPBearer()
def validate_token(
token: Annotated[HTTPAuthorizationCredentials, Depends(token_auth_scheme)]
) -> JWTPayload:
"""Validates Bearer JWT with Auth0 configuration"""
payload = JSONWebToken(token.credentials).validate()
return JWTPayload(**payload)
This function uses a JSONWebToken
class to validate the credentials:
JSONWebToken(token.credentials).validate()
The class is built using Auth0 configuration data stored in the settings
object. You can look at how to set up environment settings values here.
from dataclasses import dataclass
import jwt
from exceptions.custom_exceptions import (
BadCredentialsException,
UnableCredentialsException,
)
from .config import settings
# https://github.com/auth0-developer-hub/api_fastapi_python_hello-world/blob/basic-authorization/application/json_web_token.py
@dataclass
class JSONWebToken:
"""Perform JSON Web Token (JWT) validation using PyJWT"""
jwt_access_token: str
auth0_issuer_url: str = f"https://{settings.auth0_domain}/"
auth0_audience: str = settings.auth0_audience
algorithm: str = "RS256"
jwks_uri: str = f"{auth0_issuer_url}.well-known/jwks.json"
def validate(self):
try:
jwks_client = jwt.PyJWKClient(self.jwks_uri)
jwt_signing_key = jwks_client.get_signing_key_from_jwt(
self.jwt_access_token
).key
payload = jwt.decode(
self.jwt_access_token,
jwt_signing_key,
algorithms=self.algorithm,
audience=self.auth0_audience,
issuer=self.auth0_issuer_url,
)
except jwt.exceptions.PyJWKClientError:
raise UnableCredentialsException
except jwt.exceptions.InvalidTokenError as e:
print(e)
raise BadCredentialsException
return payload
The code for validating the token is from the Auth0 Developer Hub repository.
If only it were all as simple as copying this code into your API! But it's not, and it wasn't for me. A lot of configuration went into this setup in Auth0 for authorizing applications, grant types, URLs, and username-password access. It was, unfortunately, a headache.
Querying Neo4j for User Data in FastAPI
I skipped over the part where the API queries the Neo4j database using the sub
value for the user data. However, that part is included in the Github gist with the rest of the code.
Using Auth0 with Neo4j was born out of the article, Handling Authentication and Identity with Neo4j and Auth0. That said, most of the code is from Neo4j's Python manual. The code in the manual isn't typed, but I was able to find a few argument types, so you can reference my code for that aspect.
Pitfalls
Syncing users. It's not easy to keep the Auth0 user database and your Neo4j database in sync with this setup. You'll have to configure Actions to handle adding new users to the Neo4j database after they sign up with Auth0. Or, Auth0 offers other ways to sync with databases.
Duplicate users. In the article, Handling Authentication and Identity with Neo4j and Auth0, the author discusses the problem of duplicate users. These are users who signed up using different Auth0 methods (i.e., Github, Google, E-mail). He provides a remedy in the article.
Configuration. I ran into a lot of configuration errors. I had weird Python SSL certificate errors, grant type errors, password authorization errors, wrong client ID problems, etc. There's a large part of this article that isn't discussed and that's the Auth0 setup on their web platform. For navigating that subject you'll have to refer to Auth0 documentation and community forums.