28 Jan 2025
Authentication with Auth0: Part I
Authentication is one of the cornerstones of web development that almost every web developer has to work with at some point in time. Despite its shortcomings, I've been a long term proponent of AWS Cognito. This is mostly because I've heavily relied on AWS Lambda and API Gateway in the past, and Cognito's integration with other AWS services makes it the natural choice for identity provider.
However, as I've worked with it more and more, its drawbacks become more and more apparent. To name but a few:
- No supported backup mechanism. If you accidentally delete users, they are gone forever.
- Changes to basic options such as schema require re-creation of the user pool.
- Terrible API design. Some of the endpoints are just tedious to work with.
ListUsers
for instance only returns 60 users max per request. Exporting a user pool with 100,000s users is extremely inefficient - Over-complicated lambda event hooks. Having a service that's flexible and customisable is great, but AWS seem to have made is so complicated that a simple username-password flow is almost difficult to implement.
- Terrible documentation.
I've recently started working on a new web-based project Siloed that required an authentication layer for its ECS services, and rather than re-implementing the same AWS Cognito API I've built so many times, I decided to look into alternatives. Eventually, I settled on one of the industry standards, Auth0. I had never worked with Auth0 before, but its reputation for ease of use superseded it.
In this blog, I'm going to walk you through the steps that I took to get a very basic username-password authentication API built using the Auth0 Authentication API and FastAPI to signup users and generate access tokens that can be used to protect other APIs and services with JWT-parsing middleware (the latter is covered in a later post). Access tokens are going to be generated as JWTs. If you're not already familiar with what a JWT is, you can read up on them here.
Auth0 Configuration
If you don't already have an Auth0 account, head over to the management portal to create one. Once you have an account created, theres a couple of steps to prep Auth0 for use.
1. Add Username-Password-Authentication
default directory
You can configure Auth0 to store your credentials in a datastore that you host (referred to as a DB connection). However, it also ships with its own internal datastore called Username-Password-Authentication
. However, you need to configure Auth0 to use Username-Password-Authentication
as the default DB connection first.
To do this, navigate to the Settings > API Authorization Settings
section and enter Username-Password-Authentication
for the Default Directory
field.
2. Create an API
JWTs are generated with a specific audience in mind, where audience refers to the web application that is the intended final target for the token. To generate access tokens as JWTs rather than Opaque tokens, you need an audience, which you can do by heading over to the Applications > APIs
section and creating a new API.
Set the Identifier
field to match the URL your protected API is going to be listening on. Note that this should be the URL of the API that is going to be protected by the tokens NOT your authentication service. For more on the difference between JWTs and Opaque tokens, refer to the docs.
3. Create an Application (Optional)
You can optionally create an application in the Applications > Applications
section. Auth0 ships with a default application that you can use (called Default App
), but feel free to create your own. Make sure you take note of the Client ID
field since this is going to be used by the FastAPI instance.
API Endpoints
With Auth0 configured, its time to get the API in place. Auth0 implements a host of different OAuth login flows. I'm a big believer in keeping it simple, and all I really needed to get development for Siloed off the ground was a basic username-password flow that let users signup and generate access tokens. Auth0 refers to this as Resource Owner Password Flow
.
The FastAPI service that I'm going to be implementing has two basic endpoints to facilitate this.
POST /signup
to register new usersPOST /token
to generate a token
Both endpoints are going to accept a simple JSON payload that looks like the following
{
"email": String,
"password": String
}
The POST /token
endpoint will return a JWT that can be used to authenticate protected services, and will return a JSON response with the following structure.
{
"data": String
}
where the string value in the data
field will be an access token generated by Auth0 in JWT format. Critically, the Auth0 JWT has an id
claim that you can extract and use as a unique identifier for all of your users (more on this in the next post).
Auth0 Integration Layer
Now that the basic endpoints are fleshed out, the code to integrate with Auth0
needs to be put into place. I'm running everything on python3.12
with the following dependencies installed
pydantic
fastapi
uvicorn
httpx
Note the inclusion of httpx
. I'm going to be using the Auth0 Authentication API (documentation here) to create users and generate tokens, and httpx
is going to be used as the REST client.
User Signup
First, lets create the function to handle user registration. To create a user, the following arguments are needed
auth0_token_issuer
- this is your Auth0 tenant IDauth0_client_id
- this is the ID of the client you are using
import httpx
from pydantic import BaseModel, Field, StringConstraints
from typing_extensions import Annotated
class InvalidSignupError(Exception):
"""Exception raised for invalid signup credentials."""
def __init__(self, email: str):
self.email = email
super().__init__(f"Invalid signup credentials for email: {email}")
class Auth0ApiError(Exception):
"""Exception raised for errors in the Auth0 API."""
def __init__(self, operation: str, code: str):
self.code = code
self.operation = operation
message = f"Error executing operation '{operation}': received code '{code}'"
super().__init__(message)
class SignupResponse(BaseModel):
uid: Annotated[str, StringConstraints(min_length=1), Field(alias="_id")]
def signup(auth0_token_issuer: str, auth0_client_id: str, email: str, password: str):
"""Registers a new user with the provided email and password using Auth0.
Args:
email (str): The email address of the user to register.
password (str): The password for the new user.
Raises:
InvalidSignupError: If the signup request returns a 400 status code.
Auth0ApiError: If the signup request returns any other non-success status code.
"""
headers = {
"Content-Type": "application/json",
}
with httpx.Client() as client:
response = client.post(
f"{auth0_token_issuer}/dbconnections/signup",
json={
"client_id": auth0_client_id,
"email": email,
"password": password,
"connection": "Username-Password-Authentication",
},
headers=headers,
)
match response.status_code:
case 200:
return SignupResponse(**response.json())
case 400:
raise InvalidSignupError(email=email)
case _:
raise Auth0ApiError(operation="signup", code=response.status_code)
Fairly simple. All I'm doing is creating a new httpx
client instance and calling the POST /dbconnections/signup
endpoint on my Auth0
tenant, which creates the user in your Auth0
tenant. I'm then handling the response based on the returned response code, and raising any relevant exceptions.
One thing to note is that the 400 Bad Request
response is received in one of two cases
- The email provided is already in use by another user
- The email provided is not a valid email
Both are handled via the InvalidSignupError
exception.
Token Creation
To generate tokens, a few more arguments are required (see section below on details where to find these settings).
auth0_token_issuer
- the token issuer which takes the formathttps://{tenantId}.{region}.auth0.com
auth0_client_id
- this is the ID of the application you are usingauth0_client_secret
- the client secret associated with the applicationauth0_token_audience
- the Identifier of the Auth0 API that you created earlier
One very important thing to note here is that the auth0_client_secret
should not be made publicly available for any reason, and should only be shared to trusted applications, otherwise the security of your stored identities are compromised. Be very careful to not expose the client secret on your client-side applications.
The actual code looks very similar to the user signup function, except this time, httpx
is used to make a request to POST /oauth/token
. The grant_type
parameter in the POST
body is worth mentioning, as it determines that authentication flow that you want to initiate. In this case, I have set it to password
since all I want is a basic username-password flow.
class TokenResponse(BaseModel):
access_token: Annotated[str, StringConstraints(min_length=1)]
token_type: Annotated[str, StringConstraints(min_length=1)]
expires_in: int
class InvalidCredentialsError(Exception):
"""Exception raised for invalid credentials."""
def __init__(self, email: str):
self.email = email
super().__init__(f"Invalid credentials for email: {email}")
def generate_token(
auth0_token_issuer: str,
auth0_client_id: str,
auth0_client_secret: str,
auth0_token_audience: str,
email: str,
password: str
) -> TokenResponse:
"""Generates an authentication token using the provided email and password.
This function sends a POST request to the Auth0 token endpoint to obtain an
authentication token. It uses the provided email and password along with
client credentials and audience from the configuration.
Args:
email (str): The email address of the user.
password (str): The password of the user.
Returns:
TokenResponse: An object containing the authentication token and related
information.
Raises:
InvalidCredentialsError: If the provided email or password is incorrect.
Auth0ApiError: If there is an error with the Auth0 API request."""
headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
with httpx.Client() as client:
response = client.post(
f"{auth0_token_issuer}/oauth/token",
data={
"client_id": auth0_client_id,
"client_secret": auth0_client_secret,
"audience": auth0_token_audience,
"grant_type": "password",
"username": email,
"password": password,
},
headers=headers,
)
match response.status_code:
case 200:
return TokenResponse(**response.json())
case 403:
raise InvalidCredentialsError(email=email)
case _:
raise Auth0ApiError(operation="generate_token", code=response.status_code)
Just as before, the status code of the response is parsed to determine what type of response to return. 403 Forbidden
errors are returned by the Auth0 API when the credentials provided do not match, and this is handled explicitly via the InvalidCredentialsError
exception.
One important thing to note is that the Auth0 /oauth/token
endpoint returns both and ID token and an access token. As a general rule of thumb, you want to use the access token when making a request to your API, not the ID token. The reasons behind this are out of scope for this article, but there is a good blog post from Okta that goes over the differences between ID and access tokens and when to use which in detail here.
FastAPI App
Before any code can be ran, a few environment variables need to be configured.
$ export AUTH0_TOKEN_ISSUER={token-issuer} AUTH0_TOKEN_AUDIENCE={token-audience} AUTH0_CLIENT_ID={client-id} AUTH0_CLIENT_SECRET={client-secret}
A quick breakdown of these settings and what they translate to
AUTH0_TOKEN_ISSUER
- The token issuer which takes the formhttps://{tenantId}.{region}.auth0.com
AUTH0_TOKEN_AUDIENCE
- Identifier of the Auth0 API that you created earlierAUTH0_CLIENT_ID
- Client ID of your Auth0 ApplicationAUTH0_CLIENT_SECRET
- Client secret of your Auth0
When configuring the AUTH0_TOKEN_ISSUER
, make sure to use the abbreviated version of the region
. For example, if your tenant ID is test-tenant-123
and your tenant is located in the UK-1
region, the token issuer would be https://test-tenant-123.uk.auth0.com
.
Both the AUTH0_CLIENT_SECRET
and AUTH0_CLIENT_ID
parameters can be found by selecting your application from the Applications > Applications
tab of the management portal, and scrolling down to the Basic Information
section. The AUTH0_TOKEN_AUDIENCE
parameter should be set to the URL that you provided as the identifier for your Auth0 API created earlier.
With the basic code to create and authenticate identities in place, all thats left to do is to wrap everything into a FastAPI instance. The following code snippet assumes that the code defined above is located in auth.py
.
from logging import getLogger
import uvicorn
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel, SecretStr, EmailStr, StringConstraints, Field
from pydantic_settings import BaseSettings
from typing_extensions import Annotated
from auth import (
generate_token,
Auth0ApiError,
InvalidCredentialsError,
signup,
InvalidSignupError,
) # this is the auth0 code defined before hand. Make sure to change location if required.
LOGGER = getLogger()
APP = FastAPI()
class Config(BaseSettings):
app_host: Annotated[str, StringConstraints(min_length=1), Field(default="0.0.0.0")]
app_port: Annotated[int, Field(default=8080)]
auth0_token_issuer: Annotated[str, StringConstraints(min_length=1)]
auth0_token_audience: Annotated[str, StringConstraints(min_length=1)]
auth0_client_id: Annotated[str, StringConstraints(min_length=1)]
auth0_client_secret: Annotated[str, StringConstraints(min_length=1)]
CONFIG = Config()
class TokenRequest(BaseModel):
email: EmailStr
password: SecretStr
@APP.post("/token")
async def get_token_handler(request: TokenRequest) -> JSONResponse:
"""Handles the token generation request.
This asynchronous function processes a token request by validating the provided
credentials and generating an access token if the credentials are valid. It logs
the request and handles exceptions that may occur during the token generation
process.
Args:
request (TokenRequest): The request object containing the email and password.
Returns:
JSONResponse: A JSON response containing the access token if successful, or an
error message with the appropriate HTTP status code if an error occurs.
Raises:
InvalidCredentialsError: If the provided credentials are invalid.
Auth0ApiError: If there is an error with the Auth0 API during token generation.
"""
try:
token = generate_token(
CONFIG.auth0_token_issuer,
CONFIG.auth0_client_id,
CONFIG.auth0_client_secret,
CONFIG.auth0_token_audience,
request.email,
request.password.get_secret_value()
)
return {"data": token.access_token}
except InvalidCredentialsError:
LOGGER.exception(
f"Invalid credentials when generating token for email: {request.email}"
)
return JSONResponse(status_code=401, content={"error": "Unauthorized"})
except Auth0ApiError as e:
LOGGER.exception(
f"Error generating token for email: {request.email} with code: {e.code}"
)
return JSONResponse(status_code=500, content={"error": "Internal Server Error"})
class SignupRequest(BaseModel):
email: EmailStr
password: SecretStr
@APP.post("/signup")
async def signup_handler(request: SignupRequest) -> JSONResponse:
"""Handles the signup request.
This asynchronous function processes a signup request by validating the provided
credentials and generating an access token if the credentials are valid. It logs
the request and handles exceptions that may occur during the signup process.
Args:
request (SignupRequest): The request object containing the email and password.
Returns:
JSONResponse: A JSON response containing the access token if successful, or an
error message with the appropriate HTTP status code if an error occurs.
Raises:
InvalidCredentialsError: If the provided credentials are invalid.
Auth0ApiError: If there is an error with the Auth0 API during signup.
"""
try:
signup(
CONFIG.auth0_token_issuer,
CONFIG.auth0_client_id,
request.email,
request.password.get_secret_value()
)
return JSONResponse(status_code=201, content={"message": "User Created"})
except InvalidSignupError:
LOGGER.exception(f"Invalid signup credentials for email: {request.email}")
return JSONResponse(status_code=400, content={"error": "Bad Request"})
except Auth0ApiError as e:
LOGGER.exception(
f"Error executing signup for email: {request.email} with code: {e.code}"
)
return JSONResponse(status_code=500, content={"error": "Internal Server Error"})
if __name__ == "__main__":
uvicorn.run(APP, port=CONFIG.app_port, host=CONFIG.app_host)
Couple of things to break down here. First of all, the app is going to be pulling all the Auth0 configuration settings from environment variables, and I'm using pydantic_settings
to read them in. The global CONFIG
instance ensures that the app loads with all the required environment variables, and an exception will be raised if any of the required values are missing. In addition to the required Auth0 settings, I'm also adding settings for the app host and port.
The FastAPI instance is then created, and the /token
and /signup
endpoints are added. Both endpoints return a JSONResponse
instance, and call the generate_token
and signup
functions that we defined earlier, with the settings provided in the Config
class. The Auth0ApiError
exception is handled in both endpoints and converted to a 500 Internal Server Error
response, while the InvalidSignupError
and InvalidCredentialsError
errors are handled and converted to 400 Bad Request
and 401 Unauthorized
errors respectively.
The following command can then be used to run the app
$ python app.py
Once you have an API in place to generate JWTs, you can protect your other services by requiring an access token to be provided with each request. This requires validating the JWTs signature and parsing the token claims, which can all be wrapped into a FastAPI middleware instance. I'm going to be covering this in detail in Part II of this blog post.
Final Notes
That's its for today folks. If you enjoyed this article, consider following us on LinkedIn to get updates on future content and what we are working on, or send me a connection request directly. Don't forget to check out the rest of our content and stay tuned for weekly blog posts on tech and business.