auth-pipeline (Python / FastAPI)
A unified, strategy-based auth system for FastAPI. Google OAuth, credentials, or anything else — every strategy resolves to the same result shape, and you decide what happens next in one place.
This is the Python port of the JS auth-kit package — same architecture,
same unified shape, ported to FastAPI's async/dependency-injection style.
pip install -e .
The core idea
Authenticating with Google and authenticating with email/password are fundamentally different processes — but what your server does after either succeeds is usually identical: look up or create a user, issue a session, redirect or respond. auth-pipeline splits this into two layers:
| Layer | Responsibility | Where it lives |
|---|---|---|
| Strategy | Talk to the provider, return a normalized user | auth_pipeline/strategies/*.py |
| AuthKit | Wire strategies to routes, call your hook | auth_pipeline/core/auth_kit.py |
| Your hook | Decide what "logged in" means for your app | your code |
Every strategy resolves to exactly this (AuthResult):
AuthResult(
provider="google", # or "credentials", "github", ...
user=AuthUser(id=..., email=..., name=..., picture=..., email_verified=...),
raw={...}, # untouched provider payload, for anything custom
)
Your on_authenticate hook receives this every time, regardless of which
strategy ran. Add a GitHub strategy next month — your hook doesn't change.
Quick start
import jwt
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse, Response
from auth_pipeline import AuthKit, AuthResult, GoogleStrategy, CredentialsStrategy
app = FastAPI()
google = GoogleStrategy(
client_id=os.environ["GOOGLE_CLIENT_ID"],
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
redirect_uri="http://localhost:8000/auth/google/callback",
)
async def verify_user(email: str, password: str, request: Request):
row = await db.users.find_by_email(email) # your DB, your choice
if not row:
return None
if not CredentialsStrategy.verify_password(password, row["password_hash"]):
return None
return row
credentials = CredentialsStrategy(verify=verify_user)
async def on_authenticate(result: AuthResult, request: Request) -> Response:
user = result.user
token = jwt.encode({"id": user.id, "email": user.email}, JWT_SECRET, algorithm="HS256")
if result.provider == "google":
return RedirectResponse(f"http://localhost:3000/auth/callback?token={token}")
return JSONResponse({"token": token, "user": user.to_dict()})
auth_kit = AuthKit(
strategies={"google": google, "credentials": credentials},
on_authenticate=on_authenticate,
)
@app.get("/auth/google")
async def google_start(request: Request):
return await auth_kit.initiate("google", request)
@app.get("/auth/google/callback")
async def google_callback(request: Request):
return await auth_kit.complete("google", request)
@app.post("/auth/login")
async def login(request: Request):
return await auth_kit.complete("credentials", request)
Notice: initiate() and complete() are the same two calls for every
strategy — only the string changes.
Strategies included
GoogleStrategy
GoogleStrategy(
client_id, # required
client_secret, # required
redirect_uri, # required
scopes=("email", "profile"),
access_type="offline",
prompt="consent",
)
result.raw contains {"profile": ..., "tokens": ...} — the full Google
profile and token response, in case you need fields beyond the normalized
set (e.g. locale, or Google's refresh_token to call Google APIs later).
CredentialsStrategy
CredentialsStrategy(
verify=my_verify_fn, # async def(email: str, password: str, request: Request) -> dict | None
email_field="email",
password_field="password",
)
Like the JS version, this strategy does not touch your database. You
provide verify; it handles request parsing, password comparison, and
error normalization. Swap Postgres for MongoDB for an in-memory dict
without touching this package at all.
A verify that raises an exception is treated as a 500 (server/DB error),
not a 401 (bad password) — keeps your error codes honest.
Password helpers (shared so signup and login use identical hashing):
hashed = CredentialsStrategy.hash_password("plaintext") # for signup
ok = CredentialsStrategy.verify_password("plaintext", hashed) # used internally by verify()
Signup itself is intentionally not abstracted — creating a user is a
write to your specific schema. See server_example.py for a plain FastAPI
route using CredentialsStrategy.hash_password() for consistency.
Adding your own strategy (e.g. GitHub)
Implement two async methods on a BaseStrategy subclass:
from auth_pipeline import BaseStrategy, AuthError, AuthResult, AuthUser
from fastapi import Request
from fastapi.responses import RedirectResponse, Response
class GitHubStrategy(BaseStrategy):
name = "github"
def __init__(self, *, client_id, client_secret, redirect_uri):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
async def initiate(self, request: Request) -> Response:
url = f"https://github.com/login/oauth/authorize?client_id={self.client_id}&redirect_uri={self.redirect_uri}"
return RedirectResponse(url)
async def authenticate(self, request: Request) -> AuthResult:
code = request.query_params.get("code")
if not code:
raise AuthError("Missing code", status=400)
# ... exchange code, fetch profile via GitHub's API ...
return AuthResult(
provider=self.name,
user=AuthUser(id=..., email=..., name=..., picture=..., email_verified=...),
raw={"profile": profile, "tokens": tokens},
)
Register it and it slots into the exact same on_authenticate hook:
auth_kit.use("github", GitHubStrategy(...))
@app.get("/auth/github")
async def github_start(request: Request):
return await auth_kit.initiate("github", request)
@app.get("/auth/github/callback")
async def github_callback(request: Request):
return await auth_kit.complete("github", request)
Nothing else in your app needs to know GitHub exists.
Error handling
Every strategy should raise AuthError (not a plain Exception) for known
failure cases:
raise AuthError("Invalid email or password", status=401, code="INVALID_CREDENTIALS")
Unexpected errors get auto-wrapped by AuthKit into an AuthError with
status=500, code="STRATEGY_ERROR", so on_error always receives a
consistent shape:
err.message # human-readable
err.status # HTTP status to respond with
err.code # machine-readable, e.g. "INVALID_CREDENTIALS"
err.cause # original exception, if any (useful for logging)
What this package intentionally does NOT do
- No database.
CredentialsStrategy.verifyand youron_authenticatehook are where persistence happens — bring your own ORM/driver (SQLAlchemy, Tortoise, motor, raw asyncpg, whatever). - No JWT signing.
on_authenticategets a normalized user; signing tokens, setting cookies, and session management are yours to control. - No signup flow. Creating a user is schema-specific — write that
route directly, using
CredentialsStrategy.hash_password()for consistency.
File structure
auth_pipeline/
├── core/
│ ├── auth_kit.py ← orchestrator: initiate(), complete(), use()
│ ├── base_strategy.py ← ABC every strategy implements
│ ├── result.py ← AuthResult / AuthUser dataclasses
│ └── errors.py ← AuthError exception
├── strategies/
│ ├── google.py
│ └── credentials.py
├── __init__.py ← public exports
server_example.py ← full working example (Google + credentials)
pyproject.toml
Running the example
pip install -e .
pip install uvicorn pyjwt
export GOOGLE_CLIENT_ID=your-id
export GOOGLE_CLIENT_SECRET=your-secret
export JWT_SECRET=some-long-random-string
uvicorn server_example:app --reload --port 8000
Differences from the JS version (and why)
| JS (Express) | Python (FastAPI) | Reason |
|---|---|---|
BaseStrategy is duck-typed |
BaseStrategy is an ABC |
Python has real interfaces — subclasses missing a method can't even be instantiated |
| Result is a plain object | AuthResult / AuthUser are @dataclass |
Typed, autocompletable, still just data |
initiate() calls res.redirect(url) |
initiate() returns a RedirectResponse |
FastAPI handlers return responses rather than mutating one |
bcryptjs (pure JS) |
bcrypt (C extension, prebuilt wheels) |
Python's bcrypt ships prebuilt wheels for all major platforms, so there's no native-build pain like Node sometimes has |
The wire shape (emailVerified, camelCase) is kept identical to the JS
package on purpose — if your frontend talks to both a Node and a Python
service, the JSON looks the same either way.
License
MIT
Metadata
Release files for auth-pipeline 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| auth_pipeline-1.0.0.tar.gz | 14.8 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| auth_pipeline-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 28.7 kB
Release files / auth_pipeline-1.0.0.tar.gz
| Download URL | auth_pipeline-1.0.0.tar.gz |
|---|---|
| Size | 14.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1151f1f9a93fd5925895c06bc2e040f050524d075a416ec3565e5e2b45afd59b
|
|
BLAKE2b-256 checksum How to use checksums |
2f85741aea776e263340ead13ee0a4464a1ba73b8d8166aecae74efaa681d92c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|
Release files / auth_pipeline-1.0.0-py3-none-any.whl
| Download URL | auth_pipeline-1.0.0-py3-none-any.whl |
|---|---|
| Size | 13.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
30f93eb72bde2487705221b83a6ddaa49ae2a6da4be376803b330d473ff787f9
|
|
BLAKE2b-256 checksum How to use checksums |
6ab56c18864a76eba2ac1cccd430f8678e4ead253644596db9ee753641ea2cc9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.7
|
Security for auth-pipeline 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.