Skip to content

Commit

Permalink
Merge pull request #1 from OSSMafia/mnick/setup
Browse files Browse the repository at this point in the history
feat: init project
  • Loading branch information
mattylight22 authored Aug 14, 2024
2 parents 1b6bfdc + b3643e2 commit 79f830c
Show file tree
Hide file tree
Showing 14 changed files with 316 additions and 1 deletion.
8 changes: 8 additions & 0 deletions .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[bumpversion]
current_version = 0.0.1
commit = True
tag = True

[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Publish Python Package

on:
release:
types:
- created

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Check out the code
uses: actions/checkout@v3

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build the package
run: python -m build

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
run: |
python -m twine upload dist/*
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,4 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.ruff_cache/
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
format:
bash ./scripts/formatter.sh

lint:
bash ./scripts/linter.sh
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,27 @@
# fastapi-clerk-middleware
# FastAPI Clerk Auth Middleware


FastAPI Auth Middleware for [Clerk](https://clerk.com)

## Install
```bash
pip install fastapi-clerk
```

## Basic Usage
```python
from fastapi import FastAPI, Depends
from fastapi_clerk_auth import ClerkConfig, ClerkHTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder

app = FastAPI()

clerk_config = ClerkConfig(jwks_url="https://example.com/.well-known/jwks.json") # Use your Clerk JWKS endpoint

clear_auth_guard = ClerkHTTPBearer(config=clerk_config)

@app.get("/")
async def read_root(credentials: HTTPAuthorizationCredentials | None = Depends(clear_auth_guard)):
return JSONResponse(content=jsonable_encoder(credentials))
```
154 changes: 154 additions & 0 deletions fastapi_clerk_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from typing import Any
from typing import Optional

from fastapi import HTTPException
from fastapi import Request
from fastapi.openapi.models import HTTPBearer as HTTPBearerModel
from fastapi.security import HTTPAuthorizationCredentials as FastAPIHTTPAuthorizationCredentials
from fastapi.security import HTTPBearer
from fastapi.security.utils import get_authorization_scheme_param
import jwt
from jwt import PyJWKClient
from pydantic import BaseModel
from starlette.status import HTTP_403_FORBIDDEN
from typing_extensions import Annotated
from typing_extensions import Doc


class ClerkConfig(BaseModel):
jwks_url: str
audience: str | None = None
issuer: str | None = None
verify_exp: bool = True
verify_aud: bool = False
verify_iss: bool = False
jwks_cache_keys: bool = False
jwks_max_cached_keys: int = 16
jwks_cache_set: bool = True
jwks_lifespan: int = 300
jwks_headers: Optional[dict[str, Any]] = None
jwks_client_timeout: int = 30


class HTTPAuthorizationCredentials(FastAPIHTTPAuthorizationCredentials):
decoded: dict | None = None


class ClerkHTTPBearer(HTTPBearer):
def __init__(
self,
config: ClerkConfig,
bearerFormat: Annotated[Optional[str], Doc("Bearer token format.")] = None,
scheme_name: Annotated[
Optional[str],
Doc(
"""
Security scheme name.
It will be included in the generated OpenAPI (e.g. visible at `/docs`).
"""
),
] = None,
description: Annotated[
Optional[str],
Doc(
"""
Security scheme description.
It will be included in the generated OpenAPI (e.g. visible at `/docs`).
"""
),
] = None,
auto_error: Annotated[
bool,
Doc(
"""
By default, if the HTTP Bearer token not provided (in an
`Authorization` header), `HTTPBearer` will automatically cancel the
request and send the client an error.
If `auto_error` is set to `False`, when the HTTP Bearer token
is not available, instead of erroring out, the dependency result will
be `None`.
This is useful when you want to have optional authentication.
It is also useful when you want to have authentication that can be
provided in one of multiple optional ways (for example, in an HTTP
Bearer token or in a cookie).
"""
),
] = True,
debug_mode: bool = False,
):
super().__init__(bearerFormat=bearerFormat, scheme_name=scheme_name, description=description, auto_error=auto_error)
self.model = HTTPBearerModel(bearerFormat=bearerFormat, description=description)
self.scheme_name = scheme_name or self.__class__.__name__
self.auto_error = auto_error
self.config = config
self._check_config()
self.jwks_url: str = config.jwks_url
self.audience: str | None = config.audience
self.issuer: str | None = config.issuer
self.jwks_client: PyJWKClient = PyJWKClient(
uri=config.jwks_url,
cache_keys=config.jwks_cache_keys,
max_cached_keys=config.jwks_max_cached_keys,
cache_jwk_set=config.jwks_cache_set,
lifespan=config.jwks_lifespan,
headers=config.jwks_headers,
timeout=config.jwks_client_timeout,
)
self.debug_mode = debug_mode

def _check_config(self) -> None:
if not self.config.audience and self.config.verify_aud:
raise ValueError("Audience must be set in config because verify_aud is True")
if not self.config.issuer and self.config.verify_iss:
raise ValueError("Issuer must be set in config because verify_iss is True")

def _decode_token(self, token: str) -> dict | None:
try:
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
key=signing_key.key,
audience=self.audience,
issuer=self.issuer,
algorithms=["RS256"],
options={
"verify_exp": self.config.verify_exp,
"verify_aud": self.config.verify_aud,
"verify_iss": self.config.verify_iss,
},
)
except Exception as e:
if self.debug_mode:
raise e
return None

async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]:
authorization = request.headers.get("Authorization")
scheme, credentials = get_authorization_scheme_param(authorization)
if not (authorization and scheme and credentials):
if self.auto_error:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated")
else:
return None
if scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Invalid authentication credentials",
)
else:
return None

decoded_token: dict | None = self._decode_token(token=credentials)
if not decoded_token and self.auto_error:
raise HTTPException(
status_code=HTTP_403_FORBIDDEN,
detail="Invalid authentication credentials",
)

return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials, decoded=decoded_token)
26 changes: 26 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[project]
name = "fastapi_clerk_auth"
version = "0.0.1"
description = "FastAPI Auth Middleware for [Clerk](https://clerk.com)"
readme = "README.md"
requires-python = ">=3.9"
authors = [
{ name = "OSS Mafia", email = "[email protected]" },
]
dependencies = [
"fastapi>=0.95.0",
"PyJWT>=2.0.0",
]

[project.urls]
"Homepage" = "https://github.com/OSSMafia/fastapi-clerk-middleware"
"Source" = "https://github.com/OSSMafia/fastapi-clerk-middleware"

[tools.setuptools.packages.find]
where = "fastapi_clerk_auth/"
include = ["fastapi_clerk_auth"]
namespaces = true

[build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta"
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ruff==0.2.0
pytest
pytest-asyncio
pytest-mock
bump2version
25 changes: 25 additions & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
line-length = 180

[lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by black
"B008", # do not perform function calls in argument defaults
"C901", # too complex
"W191", # indentation contains tabs
"B026", # keyword argument unpacking
]

[lint.isort]
known-first-party = ["fastapi_clerk_auth"]
combine-as-imports = true
force-single-line = true
force-sort-within-sections = true
6 changes: 6 additions & 0 deletions scripts/formatter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/bin/sh -e
set -x

pip install -r requirements-dev.txt
ruff check fastapi_clerk_auth tests --fix
ruff format fastapi_clerk_auth tests
5 changes: 5 additions & 0 deletions scripts/linter.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh -e
set -x

pip install -r requirements-dev.txt
ruff check fastapi_clerk_auth tests
7 changes: 7 additions & 0 deletions scripts/version_major.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh -e

set -x

pip install bump2version

bump2version major
7 changes: 7 additions & 0 deletions scripts/version_minor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh -e

set -x

pip install bump2version

bump2version minor
7 changes: 7 additions & 0 deletions scripts/version_patch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh -e

set -x

pip install bump2version

bump2version patch

0 comments on commit 79f830c

Please sign in to comment.