Skip to main content

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.verify and your on_authenticate hook are where persistence happens — bring your own ORM/driver (SQLAlchemy, Tortoise, motor, raw asyncpg, whatever).
  • No JWT signing. on_authenticate gets 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)

Source distribution for auth-pipeline 1.0.0
File Size Uploaded Provenance
auth_pipeline-1.0.0.tar.gz 14.8 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for auth-pipeline 1.0.0
File Interpreter ABI Platform
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.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page