Skip to content

Commit 79f830c

Browse files
authored
Merge pull request #1 from OSSMafia/mnick/setup
feat: init project
2 parents 1b6bfdc + b3643e2 commit 79f830c

File tree

14 files changed

+316
-1
lines changed

14 files changed

+316
-1
lines changed

.bumpversion.cfg

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[bumpversion]
2+
current_version = 0.0.1
3+
commit = True
4+
tag = True
5+
6+
[bumpversion:file:pyproject.toml]
7+
search = version = "{current_version}"
8+
replace = version = "{new_version}"

.github/workflows/publish.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Publish Python Package
2+
3+
on:
4+
release:
5+
types:
6+
- created
7+
8+
jobs:
9+
build:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Check out the code
14+
uses: actions/checkout@v3
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v4
18+
with:
19+
python-version: '3.11'
20+
21+
- name: Install dependencies
22+
run: |
23+
python -m pip install --upgrade pip
24+
pip install build twine
25+
26+
- name: Build the package
27+
run: python -m build
28+
29+
- name: Publish to PyPI
30+
env:
31+
TWINE_USERNAME: __token__
32+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
33+
run: |
34+
python -m twine upload dist/*

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,4 @@ cython_debug/
160160
# and can be added to the global gitignore or merged into this file. For a more nuclear
161161
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162162
#.idea/
163+
.ruff_cache/

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
format:
2+
bash ./scripts/formatter.sh
3+
4+
lint:
5+
bash ./scripts/linter.sh

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,27 @@
1-
# fastapi-clerk-middleware
1+
# FastAPI Clerk Auth Middleware
2+
3+
24
FastAPI Auth Middleware for [Clerk](https://clerk.com)
5+
6+
## Install
7+
```bash
8+
pip install fastapi-clerk
9+
```
10+
11+
## Basic Usage
12+
```python
13+
from fastapi import FastAPI, Depends
14+
from fastapi_clerk_auth import ClerkConfig, ClerkHTTPBearer, HTTPAuthorizationCredentials
15+
from fastapi.responses import JSONResponse
16+
from fastapi.encoders import jsonable_encoder
17+
18+
app = FastAPI()
19+
20+
clerk_config = ClerkConfig(jwks_url="https://example.com/.well-known/jwks.json") # Use your Clerk JWKS endpoint
21+
22+
clear_auth_guard = ClerkHTTPBearer(config=clerk_config)
23+
24+
@app.get("/")
25+
async def read_root(credentials: HTTPAuthorizationCredentials | None = Depends(clear_auth_guard)):
26+
return JSONResponse(content=jsonable_encoder(credentials))
27+
```

fastapi_clerk_auth/__init__.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from typing import Any
2+
from typing import Optional
3+
4+
from fastapi import HTTPException
5+
from fastapi import Request
6+
from fastapi.openapi.models import HTTPBearer as HTTPBearerModel
7+
from fastapi.security import HTTPAuthorizationCredentials as FastAPIHTTPAuthorizationCredentials
8+
from fastapi.security import HTTPBearer
9+
from fastapi.security.utils import get_authorization_scheme_param
10+
import jwt
11+
from jwt import PyJWKClient
12+
from pydantic import BaseModel
13+
from starlette.status import HTTP_403_FORBIDDEN
14+
from typing_extensions import Annotated
15+
from typing_extensions import Doc
16+
17+
18+
class ClerkConfig(BaseModel):
19+
jwks_url: str
20+
audience: str | None = None
21+
issuer: str | None = None
22+
verify_exp: bool = True
23+
verify_aud: bool = False
24+
verify_iss: bool = False
25+
jwks_cache_keys: bool = False
26+
jwks_max_cached_keys: int = 16
27+
jwks_cache_set: bool = True
28+
jwks_lifespan: int = 300
29+
jwks_headers: Optional[dict[str, Any]] = None
30+
jwks_client_timeout: int = 30
31+
32+
33+
class HTTPAuthorizationCredentials(FastAPIHTTPAuthorizationCredentials):
34+
decoded: dict | None = None
35+
36+
37+
class ClerkHTTPBearer(HTTPBearer):
38+
def __init__(
39+
self,
40+
config: ClerkConfig,
41+
bearerFormat: Annotated[Optional[str], Doc("Bearer token format.")] = None,
42+
scheme_name: Annotated[
43+
Optional[str],
44+
Doc(
45+
"""
46+
Security scheme name.
47+
48+
It will be included in the generated OpenAPI (e.g. visible at `/docs`).
49+
"""
50+
),
51+
] = None,
52+
description: Annotated[
53+
Optional[str],
54+
Doc(
55+
"""
56+
Security scheme description.
57+
58+
It will be included in the generated OpenAPI (e.g. visible at `/docs`).
59+
"""
60+
),
61+
] = None,
62+
auto_error: Annotated[
63+
bool,
64+
Doc(
65+
"""
66+
By default, if the HTTP Bearer token not provided (in an
67+
`Authorization` header), `HTTPBearer` will automatically cancel the
68+
request and send the client an error.
69+
70+
If `auto_error` is set to `False`, when the HTTP Bearer token
71+
is not available, instead of erroring out, the dependency result will
72+
be `None`.
73+
74+
This is useful when you want to have optional authentication.
75+
76+
It is also useful when you want to have authentication that can be
77+
provided in one of multiple optional ways (for example, in an HTTP
78+
Bearer token or in a cookie).
79+
"""
80+
),
81+
] = True,
82+
debug_mode: bool = False,
83+
):
84+
super().__init__(bearerFormat=bearerFormat, scheme_name=scheme_name, description=description, auto_error=auto_error)
85+
self.model = HTTPBearerModel(bearerFormat=bearerFormat, description=description)
86+
self.scheme_name = scheme_name or self.__class__.__name__
87+
self.auto_error = auto_error
88+
self.config = config
89+
self._check_config()
90+
self.jwks_url: str = config.jwks_url
91+
self.audience: str | None = config.audience
92+
self.issuer: str | None = config.issuer
93+
self.jwks_client: PyJWKClient = PyJWKClient(
94+
uri=config.jwks_url,
95+
cache_keys=config.jwks_cache_keys,
96+
max_cached_keys=config.jwks_max_cached_keys,
97+
cache_jwk_set=config.jwks_cache_set,
98+
lifespan=config.jwks_lifespan,
99+
headers=config.jwks_headers,
100+
timeout=config.jwks_client_timeout,
101+
)
102+
self.debug_mode = debug_mode
103+
104+
def _check_config(self) -> None:
105+
if not self.config.audience and self.config.verify_aud:
106+
raise ValueError("Audience must be set in config because verify_aud is True")
107+
if not self.config.issuer and self.config.verify_iss:
108+
raise ValueError("Issuer must be set in config because verify_iss is True")
109+
110+
def _decode_token(self, token: str) -> dict | None:
111+
try:
112+
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
113+
return jwt.decode(
114+
token,
115+
key=signing_key.key,
116+
audience=self.audience,
117+
issuer=self.issuer,
118+
algorithms=["RS256"],
119+
options={
120+
"verify_exp": self.config.verify_exp,
121+
"verify_aud": self.config.verify_aud,
122+
"verify_iss": self.config.verify_iss,
123+
},
124+
)
125+
except Exception as e:
126+
if self.debug_mode:
127+
raise e
128+
return None
129+
130+
async def __call__(self, request: Request) -> Optional[HTTPAuthorizationCredentials]:
131+
authorization = request.headers.get("Authorization")
132+
scheme, credentials = get_authorization_scheme_param(authorization)
133+
if not (authorization and scheme and credentials):
134+
if self.auto_error:
135+
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Not authenticated")
136+
else:
137+
return None
138+
if scheme.lower() != "bearer":
139+
if self.auto_error:
140+
raise HTTPException(
141+
status_code=HTTP_403_FORBIDDEN,
142+
detail="Invalid authentication credentials",
143+
)
144+
else:
145+
return None
146+
147+
decoded_token: dict | None = self._decode_token(token=credentials)
148+
if not decoded_token and self.auto_error:
149+
raise HTTPException(
150+
status_code=HTTP_403_FORBIDDEN,
151+
detail="Invalid authentication credentials",
152+
)
153+
154+
return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials, decoded=decoded_token)

pyproject.toml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[project]
2+
name = "fastapi_clerk_auth"
3+
version = "0.0.1"
4+
description = "FastAPI Auth Middleware for [Clerk](https://clerk.com)"
5+
readme = "README.md"
6+
requires-python = ">=3.9"
7+
authors = [
8+
{ name = "OSS Mafia", email = "[email protected]" },
9+
]
10+
dependencies = [
11+
"fastapi>=0.95.0",
12+
"PyJWT>=2.0.0",
13+
]
14+
15+
[project.urls]
16+
"Homepage" = "https://github.com/OSSMafia/fastapi-clerk-middleware"
17+
"Source" = "https://github.com/OSSMafia/fastapi-clerk-middleware"
18+
19+
[tools.setuptools.packages.find]
20+
where = "fastapi_clerk_auth/"
21+
include = ["fastapi_clerk_auth"]
22+
namespaces = true
23+
24+
[build-system]
25+
requires = ["setuptools", "setuptools-scm"]
26+
build-backend = "setuptools.build_meta"

requirements-dev.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
ruff==0.2.0
2+
pytest
3+
pytest-asyncio
4+
pytest-mock
5+
bump2version

ruff.toml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
line-length = 180
2+
3+
[lint]
4+
select = [
5+
"E", # pycodestyle errors
6+
"W", # pycodestyle warnings
7+
"F", # pyflakes
8+
"I", # isort
9+
"B", # flake8-bugbear
10+
"C4", # flake8-comprehensions
11+
"UP", # pyupgrade
12+
]
13+
ignore = [
14+
"E501", # line too long, handled by black
15+
"B008", # do not perform function calls in argument defaults
16+
"C901", # too complex
17+
"W191", # indentation contains tabs
18+
"B026", # keyword argument unpacking
19+
]
20+
21+
[lint.isort]
22+
known-first-party = ["fastapi_clerk_auth"]
23+
combine-as-imports = true
24+
force-single-line = true
25+
force-sort-within-sections = true

scripts/formatter.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/bin/sh -e
2+
set -x
3+
4+
pip install -r requirements-dev.txt
5+
ruff check fastapi_clerk_auth tests --fix
6+
ruff format fastapi_clerk_auth tests

0 commit comments

Comments
 (0)