engineering
python
auth0

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 users
  • POST /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

  1. auth0_token_issuer - this is your Auth0 tenant ID
  2. auth0_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

  1. The email provided is already in use by another user
  2. 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).

  1. auth0_token_issuer - the token issuer which takes the format https://{tenantId}.{region}.auth0.com
  2. auth0_client_id - this is the ID of the application you are using
  3. auth0_client_secret - the client secret associated with the application
  4. auth0_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

  1. AUTH0_TOKEN_ISSUER - The token issuer which takes the form https://{tenantId}.{region}.auth0.com
  2. AUTH0_TOKEN_AUDIENCE - Identifier of the Auth0 API that you created earlier
  3. AUTH0_CLIENT_ID - Client ID of your Auth0 Application
  4. AUTH0_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.