*Authentication is one of those things that we all have to do for almost all our
projects. It is so important and so crucial, and yet we almost have to build it
from scratch each time we set up a project. Often, people opt for ready made
solutions, but they pay for it. There are open source solutions too. On this
post I'll talk about how I set authentication on my website.*
First thing I know I do not want to do is have a table of passwords for users
who just want to post comments on the website. I also want to avoid letting
just anyone post comments. Why? it's obvious I'm looking for a solution that
will require the least effort from me. Not having any authentication may be
easy to achieve, but managing comments and cleaning up SPAM is another thing I
want to avoid worrying about. Instead, I'll keep a record of users, and tokens.
## DB Tables
These are the database table sql model definitions.
```python
import datetime
from sqlalchemy import UniqueConstraint
from sqlmodel import Column, Enum, Field, Relationship, SQLModel
class User(SQLModel, table=True):
__table_args__ = (UniqueConstraint("username"),)
id: int | None = Field(default=None, primary_key=True)
username: str = Field(unique=True)
user_type: UserType = Field(
default=UserType.member, sa_column=Column(Enum(UserType))
)
profile: Union["Profile", None] = Relationship(
back_populates="user", sa_relationship_kwargs={"lazy": "joined"}
)
auth_identity: List["AuthIdentity"] = Relationship(
back_populates="user", sa_relationship_kwargs={"lazy": "joined"}
)
class AuthIdentity(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int | None = Field(
default=None,
foreign_key="user.id",
)
user: User | None = Relationship(
back_populates="auth_identity",
sa_relationship_kwargs={"lazy": "joined"},
)
provider: AuthProvider = Field(sa_column=Column(Enum(AuthProvider)))
provider_user_id: str | None
password_hash: str | None
class Profile(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
user_id: int | None = Field(
default=None,
foreign_key="user.id",
)
user: User | None = Relationship(
back_populates="profile", sa_relationship_kwargs={"lazy": "joined"}
)
firstName: str
lastName: str
```
The way it works, there is `user` table, a `profile` table and an
`authidentity` table. The `User` table is the table that eventually links to
the other entities on the website. For example, for my website, all comments
are owned by a user. But I don't store display information on the `user`,
instead those are stored on the `profile`. In the example, I am storing first
and last name information on the `profile` table. It's good practice to have a
separate table for this, as it'll make it easier to make changes in the future
(should you ever need to make changes) without accidentally affecting other
relationships. Then the `user` could have multiple `AuthIdentities`. This will
permit a `user` identified by its primary key, the `username` which in practice
is also the user's email address, to have multiple ways of being authenticated.
In practice, it'll permit a user with the email address `foo@gmail.com` to be
authenticated by both facebook and google at the same time.
```mermaid
erDiagram
USER {
int id
username string
}
PROFILE {
string first_name
string last_name
}
AUTHIDENTITY {
string provider
string password_hash
}
USER ||--o| PROFILE : has-one
USER ||--o{ AUTHIDENTITY : has-one-or-more
```
## AuthLib
AuthLib is an easy one-stop module that provides solutions for all (?)
authentication methods. To quote it's own description:
> The ultimate Python library in building OAuth and OpenID Connect servers. It
> is designed from low level specifications implementations to high level
> frameworks integrations, to meet the needs of everyone.
Including it in your application is as easy as:
```sh
pip install authlib
```
## Endpoints
The way this will work, you will have to provide an endpoint on your fastapi
app that will redirect requests to your authentication provider. In my example, I'll set up the endpoint to be able to handle different authentication providers.
Part of the redirect is adding a url that your authentication provider can redirect back to. This url will be a link to another endpoint on your fastapi app, so it could check if your authentication provider did indeed authenticate the user.
```mermaid
sequenceDiagram
User->>FastAPI: I want to login to Google.
FastAPI-->>Google: Hey Google, can you authenticate this user?
Google-->FastAPI: Yeap, I know this user. His firstname and lastname are "Joe" and "Smith".
```
Finally, there is another URL that needs to tag along this whole roundtrip. It's a final redirect that your FastAPI application will redirect to and include a set of access and refresh token keys. This URL will be stored in the `state`. The `state` parameter is part of the OAuth 2.0 framework. It is used to restore state before and after authorization.
In code, it'll look like this:
```python
from typing import Annotated
from authlib.integrations.starlette_client import OAuth
from fastapi import Depends, Request
from starlette.config import Config
config = Config(".env")
def make_oauth(): # pragma: no cover
oauth = OAuth(config=config)
oauth.register(
name="google",
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
client_kwargs={
"scope": "openid email profile",
"prompt": "select_account",
},
)
oauth.register(
name="github",
access_token_url="https://github.com/login/oauth/access_token",
authorize_url="https://github.com/login/oauth/authorize",
api_base_url="https://api.github.com/",
client_kwargs={"scope": "read:user user:email"},
)
@router.get("/login/{provider}")
async def login(
request: Request,
oauth: Annotated[OAuth, Depends(make_oauth)],
provider: str,
):
redirect_path = request.query_params.get("redirect", "/")
state = urllib.parse.quote(json.dumps({"redirect": redirect_path}))
redirect_auth = request.url.components._replace(
path=f"/auth/{provider}", query=None
).geturl()
request.session.clear()
oauth_provider = getattr(oauth, provider)
return await oauth_provider.authorize_redirect(
request,
redirect_auth,
state=state,
)
@router.get("/auth/{provider}")
async def auth(
request: Request,
session: database.MakeSession,
provider: str,
oauth: Annotated[OAuth, Depends(make_oauth)],
):
token = await get_authorize_access_token(request)
# Get and decode the `state` parameter
raw_state = request.query_params.get("state", "{}")
state = json.loads(urllib.parse.unquote(raw_state))
redirect_path = state.get("redirect", "/")
user, auth_identity = ensure_user_and_auth_identity(session, token)
token = create_token(session, auth_identity)
return RedirectResponse(add_params(redirect_path, dict(token)))
```
I wanted to show the meat of the code that lets you initiate authentication via
by redirecting the various authentication providers, as well as the endpoint
that checks if the user was authenticated and finalliy redirects back the to
any other page in the from your website that could receive the access and
refresh tokens. In practice, the information returned by different
authentication providers have different structures, so
`get_authorize_access_token` and `ensure_user_and_auth_identity` will have to
be different for each authentication provider.
## Make OAuth
If you notice I'm using dependepency injection to get an instance of an OAuth with the different authorization providers registered. The config variable I pass to OAuth loads variable stored in a `.env` file. It contains client id and cient secrets for the various authentication providers.
```
GOOGLE_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
GITHUB_CLIENT_ID=XXXXXXXXXXXXXXXXXXXX
GITHUB_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
Notice that the format for the different client id and secrets are
`<NAME>_CLIENT_ID` and `<NAME>_CLIENT_SECRET`, where the name is the same name
you provide when you register authorization providers. If the name is
different, `authlib` will not be able to connect the client id and secret you
defined in your .env file with the authorization provider you're registering.
The other parameters will be different for the different authorization
parameters and some research will have to be done to find what they are.
When working correctly, you can have a page on your app redirect to `/login/{provider}`, where provider could be `google` or a different authorization provider. Your FastAPI app will redirect to the authorization provider's authorization endpoint. You'll then be able to authorize yourself, and when done, the authorization provider will forward back to your FastAPI's `/auth/{provider}`. The endpoint will ensure a `user`, `profile` and `auth_identity` object exists for the user. It'll then create `access` and `refresh` tokens that it'll forward to the final redirect url stored in the `state` parameter.