From b3643e2ce1b937c10a92ba00d484fb6b530e607e Mon Sep 17 00:00:00 2001 From: Matt Nick <68877341+mattylight22@users.noreply.github.com> Date: Wed, 14 Aug 2024 07:09:34 +0000 Subject: [PATCH] feat: init project --- .bumpversion.cfg | 8 ++ .github/workflows/publish.yml | 34 ++++++++ .gitignore | 1 + Makefile | 5 ++ README.md | 27 +++++- fastapi_clerk_auth/__init__.py | 154 +++++++++++++++++++++++++++++++++ pyproject.toml | 26 ++++++ requirements-dev.txt | 5 ++ ruff.toml | 25 ++++++ scripts/formatter.sh | 6 ++ scripts/linter.sh | 5 ++ scripts/version_major.sh | 7 ++ scripts/version_minor.sh | 7 ++ scripts/version_patch.sh | 7 ++ 14 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 .bumpversion.cfg create mode 100644 .github/workflows/publish.yml create mode 100644 Makefile create mode 100644 fastapi_clerk_auth/__init__.py create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 ruff.toml create mode 100644 scripts/formatter.sh create mode 100644 scripts/linter.sh create mode 100644 scripts/version_major.sh create mode 100644 scripts/version_minor.sh create mode 100644 scripts/version_patch.sh diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..81c00ca --- /dev/null +++ b/.bumpversion.cfg @@ -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}" \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a28e1ae --- /dev/null +++ b/.github/workflows/publish.yml @@ -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/* diff --git a/.gitignore b/.gitignore index 82f9275..cef704a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f8f77e4 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +format: + bash ./scripts/formatter.sh + +lint: + bash ./scripts/linter.sh \ No newline at end of file diff --git a/README.md b/README.md index 6eef7e3..5863740 100644 --- a/README.md +++ b/README.md @@ -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)) +``` \ No newline at end of file diff --git a/fastapi_clerk_auth/__init__.py b/fastapi_clerk_auth/__init__.py new file mode 100644 index 0000000..a94a330 --- /dev/null +++ b/fastapi_clerk_auth/__init__.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6550845 --- /dev/null +++ b/pyproject.toml @@ -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 = "dev@oss-mafia.com" }, +] +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" \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a6d8543 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +ruff==0.2.0 +pytest +pytest-asyncio +pytest-mock +bump2version \ No newline at end of file diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..1fe4d1f --- /dev/null +++ b/ruff.toml @@ -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 \ No newline at end of file diff --git a/scripts/formatter.sh b/scripts/formatter.sh new file mode 100644 index 0000000..43064c6 --- /dev/null +++ b/scripts/formatter.sh @@ -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 \ No newline at end of file diff --git a/scripts/linter.sh b/scripts/linter.sh new file mode 100644 index 0000000..ae51b00 --- /dev/null +++ b/scripts/linter.sh @@ -0,0 +1,5 @@ +#!/bin/sh -e +set -x + +pip install -r requirements-dev.txt +ruff check fastapi_clerk_auth tests \ No newline at end of file diff --git a/scripts/version_major.sh b/scripts/version_major.sh new file mode 100644 index 0000000..2bff7ba --- /dev/null +++ b/scripts/version_major.sh @@ -0,0 +1,7 @@ +#!/bin/sh -e + +set -x + +pip install bump2version + +bump2version major \ No newline at end of file diff --git a/scripts/version_minor.sh b/scripts/version_minor.sh new file mode 100644 index 0000000..233d155 --- /dev/null +++ b/scripts/version_minor.sh @@ -0,0 +1,7 @@ +#!/bin/sh -e + +set -x + +pip install bump2version + +bump2version minor \ No newline at end of file diff --git a/scripts/version_patch.sh b/scripts/version_patch.sh new file mode 100644 index 0000000..149bc38 --- /dev/null +++ b/scripts/version_patch.sh @@ -0,0 +1,7 @@ +#!/bin/sh -e + +set -x + +pip install bump2version + +bump2version patch \ No newline at end of file