Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

release 0.2 #19

Merged
merged 37 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
eaea038
refactor: openid config class
davidhuser Feb 4, 2025
e7eae3e
feat: add dependency for project roles
davidhuser Feb 5, 2025
94af6d5
feat: configurable claims and users
davidhuser Feb 5, 2025
7da24d4
feat: add tests
davidhuser Feb 6, 2025
ecb4419
feat: more tests
davidhuser Feb 6, 2025
afc8a11
feat: more tests
davidhuser Feb 6, 2025
35e1214
Merge pull request #16 from cleanenergyexchange/fix/15-jwks-caching
davidhuser Feb 6, 2025
332071b
fix: validate jti claim
davidhuser Feb 6, 2025
8aeb1f2
docs: add how to run mypy
davidhuser Feb 6, 2025
ecc2a35
chore: cleanups
davidhuser Feb 6, 2025
ae5f1d5
fix: removed debug log
davidhuser Feb 6, 2025
2f434d8
docs: update usage
davidhuser Feb 6, 2025
0d37947
chore: version bump
davidhuser Feb 6, 2025
ca4ec7d
fix: openapi schema test
davidhuser Feb 6, 2025
5613f61
Merge pull request #17 from cleanenergyexchange/release-0.2
davidhuser Feb 6, 2025
e7acfb7
chore: cleanup and docs
davidhuser Feb 7, 2025
52f094a
chore: add tests and docs for custom models
davidhuser Feb 7, 2025
3208d51
fix: remove test file for custom models
davidhuser Feb 7, 2025
1a79af2
fix: cache_duration checks
davidhuser Feb 7, 2025
c3c9c24
fix: cache_duration checks
davidhuser Feb 7, 2025
141d3ba
fix: remove .env.test vars not necessary
davidhuser Feb 7, 2025
8fc8635
chore: rename GHA workflow
davidhuser Feb 7, 2025
d68374c
docs: minor
davidhuser Feb 7, 2025
a824848
fix: test env var reverted
davidhuser Feb 7, 2025
b480306
Merge pull request #18 from cleanenergyexchange/fixes
davidhuser Feb 7, 2025
bd00e78
fix: minor log change
davidhuser Feb 7, 2025
df49f49
fix: handling error in token extraction
davidhuser Feb 7, 2025
a3704fc
refactor: header validation into class
davidhuser Feb 7, 2025
ab6e548
fix: test for www-authenticate headers in responses
davidhuser Feb 7, 2025
11567ad
chore: ruff format to 120 line-width
davidhuser Feb 7, 2025
fddf6de
feat: exceptions for 400, 401 and 403
davidhuser Feb 7, 2025
c262a8b
ci: update test job with more test coverage and bump setup-uv
davidhuser Feb 7, 2025
52be751
ci: bump setup-up
davidhuser Feb 7, 2025
5cb2ac3
docs: reviewed zitadel setup
davidhuser Feb 10, 2025
be7c062
docs: reviewed zitadel setup
davidhuser Feb 10, 2025
5dcd912
chore: rename CustomClaims to JwtClaims
davidhuser Feb 10, 2025
5b2877f
fix: less service user scopes
davidhuser Feb 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ jobs:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: uv.lock

- name: Set up Python
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test
name: test

on: pull_request

Expand All @@ -14,9 +14,8 @@ jobs:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v4
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "uv.lock"

- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -28,7 +27,7 @@ jobs:

- name: Run tests
run: |
uv run pytest tests/ -v --cov=src --cov-report=xml
uv run pytest tests/ -v --cov=src --cov=tests --cov-report=xml --cov-fail-under=100

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
Expand Down
132 changes: 76 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ including token validation, role-based access control, and Swagger UI integratio
<a href="https://pypi.org/pypi/fastapi-zitadel-auth">
<img src="https://img.shields.io/pypi/v/fastapi-zitadel-auth.svg?logo=pypi&logoColor=white&label=pypi" alt="Package version">
</a>
<a href="https://pepy.tech/projects/fastapi-zitadel-auth">
<img src="https://static.pepy.tech/badge/fastapi-zitadel-auth/month" alt="PyPI downloads">
</a>
<a href="https://python.org">
<img src="https://img.shields.io/badge/python-v3.10+-blue.svg?logo=python&logoColor=white&label=python" alt="Python versions">
</a>
<a href="https://mypy-lang.org">
<img src="https://www.mypy-lang.org/static/mypy_badge.svg" alt="mypy">
</a>
<a href="https://github.com/cleanenergyexchange/fastapi-zitadel-auth/blob/main/LICENSE">
<img src="https://badgen.net/github/license/cleanenergyexchange/fastapi-zitadel-auth/" alt="License"/>
</a>
Expand All @@ -29,6 +35,7 @@ including token validation, role-based access control, and Swagger UI integratio
* Service user authentication (JWT Profile)
* Swagger UI integration
* Type-safe token validation
* Extensible claims and user models


> [!NOTE]
Expand All @@ -43,124 +50,137 @@ including token validation, role-based access control, and Swagger UI integratio
pip install fastapi-zitadel-auth
```

```python
from fastapi import FastAPI, Security
from fastapi_zitadel_auth import ZitadelAuth, AuthConfig

auth = ZitadelAuth(AuthConfig(
client_id="your-client-id",
project_id="your-project-id",
zitadel_host="https://your-instance.zitadel.cloud"
))

app = FastAPI(
swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True,
"clientId": 'your-client-id',
"scopes": "openid profile email urn:zitadel:iam:org:project:id:zitadel:aud urn:zitadel:iam:org:projects:roles"
}
)

> [!TIP]
> This library is in active development, so breaking changes can occur.
> We recommend **pinning the version** until a stable release is available.

@app.get("/protected", dependencies=[Security(auth)])
def protected_route():
return {"message": "Access granted!"}
```

See the [Usage](#usage) section for more details.

## Usage

### Configuration

#### Zitadel

Set up a project in Zitadel according to [docs/ZITADEL_SETUP.md](docs/ZITADEL_SETUP.md).
Set up a project in Zitadel according to [docs/zitadel_setup.md](docs/zitadel_setup.md).

#### FastAPI

```python
from fastapi import FastAPI, Request, Security
from fastapi_zitadel_auth import ZitadelAuth, AuthConfig
from contextlib import asynccontextmanager

from fastapi import FastAPI, Request, Security, Depends
from pydantic import HttpUrl
from fastapi_zitadel_auth import ZitadelAuth
from fastapi_zitadel_auth.user import DefaultZitadelUser
from fastapi_zitadel_auth.exceptions import ForbiddenException

# Your Zitadel configuration
# IDs from Zitadel console, see documentation on how to set up Zitadel
CLIENT_ID = 'your-zitadel-client-id'
PROJECT_ID = 'your-zitadel-project-id'
BASE_URL = 'https://your-instance-xyz.zitadel.cloud'

# Create an AuthConfig object with your Zitadel configuration
config = AuthConfig(
client_id=CLIENT_ID,
# Create a ZitadelAuth object usable as a FastAPI dependency
zitadel_auth = ZitadelAuth(
issuer_url=HttpUrl('https://your-instance-xyz.zitadel.cloud'),
project_id=PROJECT_ID,
zitadel_host=BASE_URL,
scopes={
app_client_id=CLIENT_ID,
allowed_scopes={
"openid": "OpenID Connect",
"email": "Email",
"profile": "Profile",
"urn:zitadel:iam:org:project:id:zitadel:aud": "Audience",
"urn:zitadel:iam:org:projects:roles": "Roles",
},
}
)

# Create a ZitadelAuth object with the AuthConfig usable as a FastAPI dependency
auth = ZitadelAuth(config)

# Create a dependency to validate that the user has the required role
async def validate_is_admin_user(user: DefaultZitadelUser = Depends(zitadel_auth)) -> None:
required_role = "admin"
if required_role not in user.claims.project_roles.keys():
raise ForbiddenException(f"User does not have role assigned: {required_role}")


# Load OpenID configuration at startup
@asynccontextmanager
async def lifespan(app: FastAPI): # noqa
await zitadel_auth.openid_config.load_config()
yield


# Create a FastAPI app and configure Swagger UI
app = FastAPI(
title="fastapi-zitadel-auth demo",
lifespan=lifespan,
swagger_ui_oauth2_redirect_url="/oauth2-redirect",
swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True,
"clientId": CLIENT_ID,
"scopes": " ".join(
"scopes": " ".join( # defining the pre-selected scope ticks in the Swagger UI
[
"openid",
"email",
"profile",
"urn:zitadel:iam:org:project:id:zitadel:aud",
"email",
"urn:zitadel:iam:org:projects:roles",
"urn:zitadel:iam:org:project:id:zitadel:aud",
]
),
},
)


# Create an endpoint and protect it with the ZitadelAuth dependency
# Endpoint that requires a user to be authenticated and have the admin role
@app.get(
"/api/private",
summary="Private endpoint, requiring a valid token with `system` scope",
dependencies=[Security(auth, scopes=["system"])],
"/api/protected/admin",
summary="Protected endpoint, requires admin role",
dependencies=[Security(validate_is_admin_user)],
)
def private(request: Request):
return {
"message": f"Hello, protected world! Here is Zitadel user {request.state.user.user_id}"
}
def protected_for_admin(request: Request):
user = request.state.user
return {"message": "Hello world!", "user": user}


# Endpoint that requires a user to be authenticated and have a specific scope
@app.get(
"/api/protected/scope",
summary="Protected endpoint, requires a specific scope",
dependencies=[Security(zitadel_auth, scopes=["scope1"])],
)
def protected_by_scope(request: Request):
user = request.state.user
return {"message": "Hello world!", "user": user}

```

If you need to customize the claims or user model, see [docs/custom_claims_and_users.md](docs/custom_claims_and_users.md).

## Demo app

See `demo_project` for a complete example, including service user login. To run the demo app:
See `demo_project` for a complete example, including service user login.

To run the demo app using `uv`:

```bash
uv run demo_project/server.py
uv run demo_project/main.py
```

Then navigate to `http://localhost:8001/docs` to see the Swagger UI.


### Service user
### Service user login

Service users are "machine users" in Zitadel.

To log in as a service user, change the config in `demo_project/service_user.py`, then
Service users are "machine users" in Zitadel. To log in as a service user, download the private key from Zitadel, change the config in `demo_project/service_user.py`, then

```bash
uv run demo_project/service_user.py
```

Make sure you have a running server at `http://localhost:8001`.
Make sure you have a running server at `http://localhost:8001` (see above).

## Development

See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for development instructions.
See [docs/contributing.md](docs/contributing.md) for development instructions.


## Acknowledgements

This package was heavily inspired by [`intility/fastapi-azure-auth`](https://github.com/intility/fastapi-azure-auth/).
26 changes: 19 additions & 7 deletions demo_project/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,39 @@
FastAPI dependencies
"""

from fastapi import Depends

from fastapi_zitadel_auth import ZitadelAuth
from fastapi_zitadel_auth.exceptions import ForbiddenException
from fastapi_zitadel_auth.user import DefaultZitadelUser

try:
from demo_project.settings import get_settings
except ImportError:
# ImportError handling since it's also used in tests
from settings import get_settings

from fastapi_zitadel_auth import AuthConfig, ZitadelAuth

settings = get_settings()

config = AuthConfig(
client_id=settings.OAUTH_CLIENT_ID,
zitadel_auth = ZitadelAuth(
issuer_url=settings.ZITADEL_HOST,
project_id=settings.ZITADEL_PROJECT_ID,
zitadel_host=settings.ZITADEL_HOST,
scopes={
app_client_id=settings.OAUTH_CLIENT_ID,
allowed_scopes={
"openid": "OpenID Connect",
"email": "Email",
"profile": "Profile",
"urn:zitadel:iam:org:project:id:zitadel:aud": "Audience",
"urn:zitadel:iam:org:projects:roles": "Roles",
"urn:zitadel:iam:org:projects:roles": "Projects roles",
},
)

auth = ZitadelAuth(config)

async def validate_is_admin_user(
user: DefaultZitadelUser = Depends(zitadel_auth),
) -> None:
"""Validate that the authenticated user is a user with a specific role"""
required_role = "admin"
if required_role not in user.claims.project_roles.keys():
raise ForbiddenException(f"User does not have role assigned: {required_role}")
Loading