Skip to content

Commit

Permalink
Merge pull request #9 from cleanenergyexchange/dev
Browse files Browse the repository at this point in the history
fix: nbf claim and zitadel_host
  • Loading branch information
davidhuser authored Dec 5, 2024
2 parents c56925d + 4967707 commit b01a855
Show file tree
Hide file tree
Showing 12 changed files with 107 additions and 72 deletions.
31 changes: 20 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# FastAPI Zitadel Auth

FastAPI Zitadel Auth is a Python package that simplifies OAuth2/OIDC authentication in FastAPI applications
using [Zitadel](https://zitadel.com/) as the identity provider.
It handles token validation, role-based access control, and Swagger UI integration with just a few lines of code.
Simplify OAuth2 authentication in FastAPI apps using [**Zitadel**](https://zitadel.com/) as the identity service,
including token validation, role-based access control, and Swagger UI integration.


<a href="https://github.com/cleanenergyexchange/fastapi-zitadel-auth/actions/workflows/test.yml" target="_blank">
<img src="https://github.com/cleanenergyexchange/fastapi-zitadel-auth/actions/workflows/test.yml/badge.svg" alt="Test status">
Expand All @@ -23,11 +23,12 @@ It handles token validation, role-based access control, and Swagger UI integrati

## Features

* Authorization Code Flow with PKCE
* JWT signature validation using JWKS obtained from Zitadel
* Service User authentication using JWT Profiles
* Authorization Code flow with PKCE
* JWT validation using Zitadel JWKS
* Role-based access control using Zitadel roles
* Service user authentication (JWT Profile)
* Swagger UI integration
* Zitadel roles as scopes
* Type-safe token validation


> [!NOTE]
Expand All @@ -49,10 +50,17 @@ from fastapi_zitadel_auth import ZitadelAuth, AuthConfig
auth = ZitadelAuth(AuthConfig(
client_id="your-client-id",
project_id="your-project-id",
base_url="https://your-instance.zitadel.cloud"
zitadel_host="https://your-instance.zitadel.cloud"
))

app = FastAPI()
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"
}
)


@app.get("/protected", dependencies=[Security(auth)])
def protected_route():
Expand All @@ -67,7 +75,7 @@ See the [Usage](#usage) section for more details.

#### Zitadel

Set up a new OAuth2 client in Zitadel according to the [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

Expand All @@ -84,7 +92,7 @@ BASE_URL = 'https://your-instance-xyz.zitadel.cloud'
config = AuthConfig(
client_id=CLIENT_ID,
project_id=PROJECT_ID,
base_url=BASE_URL,
zitadel_host=BASE_URL,
scopes={
"openid": "OpenID Connect",
"email": "Email",
Expand Down Expand Up @@ -116,6 +124,7 @@ app = FastAPI(
},
)


# Create an endpoint and protect it with the ZitadelAuth dependency
@app.get(
"/api/private",
Expand Down
2 changes: 1 addition & 1 deletion demo_project/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
config = AuthConfig(
client_id=settings.OAUTH_CLIENT_ID,
project_id=settings.ZITADEL_PROJECT_ID,
base_url=settings.ZITADEL_HOST,
zitadel_host=settings.ZITADEL_HOST,
scopes={
"openid": "OpenID Connect",
"email": "Email",
Expand Down
27 changes: 15 additions & 12 deletions demo_project/service_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@
import jwt as pyjwt
from httpx import AsyncClient

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

# CONFIG: Replace the following values with your own

# UPDATE THIS VALUE ----------------------------------------------
# The service account private key file downloaded from Zitadel
SERVICE_USER_PRIVATE_KEY_FILE = "service_user.json"

# The Zitadel instance URL
ZITADEL_HOST = "https://myinstance.zitadel.cloud"

# The project ID for which the service account is created
ZITADEL_PROJECT_ID = "1234567"
# ---------------------------------------------------------------

# Loading the service account private key JSON file
with open(SERVICE_USER_PRIVATE_KEY_FILE, "r") as file:
json_data = json.load(file)

# END CONFIG

# get settings from the settings module for simplicity
settings = get_settings()

# Extracting necessary values from the JSON data
private_key = json_data["key"]
Expand All @@ -36,7 +37,7 @@
payload = {
"iss": user_id,
"sub": user_id,
"aud": ZITADEL_HOST,
"aud": str(settings.ZITADEL_HOST).rstrip("/"), # Remove trailing slash
"iat": int(time.time()),
"exp": int(time.time()) + 3600, # Token expires in 1 hour
}
Expand All @@ -57,14 +58,16 @@ async def main():
"email",
"profile",
"urn:zitadel:iam:org:projects:roles",
f"urn:zitadel:iam:org:project:id:{ZITADEL_PROJECT_ID}:aud",
f"urn:zitadel:iam:org:project:id:{settings.ZITADEL_PROJECT_ID}:aud",
]
),
"assertion": jwt_token,
}

# Making a POST request to the OAuth2 token endpoint
response = await client.post(url=f"{ZITADEL_HOST}/oauth/v2/token", data=data)
response = await client.post(
url=f"{settings.ZITADEL_HOST}oauth/v2/token", data=data
)

# Handling the response
response.raise_for_status()
Expand Down
85 changes: 54 additions & 31 deletions docs/ZITADEL_SETUP.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,54 @@
## Zitadel setup

### Project
* Create a new project.
* in the General settings, tick **"Assert Roles on Authentication"** and **"Check authorization on Authentication"**
* Note the **project ID** (also called "resource Id")
* Under Roles, **create a new role** with key: `user` and Display Name "user" and assign it to the project.

### App 1: API
* Create a new application in the project of **type "API"** and **Authentication Method "JWT (Private Key JWT)"**
* Create a key of type "JSON"

### App 2: User Agent
* Create a new application in the project of **type "User Agent"** and **Authentication Method "PKCE"**.
* Toggle "Development Mode" to allow non-https redirect URIs
* Under **"Redirect URIs"**, add `http://localhost:8001/oauth2-redirect`
* Token settings
* Change **"Auth Token Type"** from "Bearer Token" to **"JWT"**
* Tick **"Add user roles to the access token"**
* Tick **"User roles inside ID token"**
* Note the **Client Id**

### User creation
* Create a **new User** in the Zitadel instance.
* Under Authorizations, create **new authorization** by searching for the project name and **assign the "user" role** to the new user

### Service User creation
* Create a **new Service User** in the Zitadel instance and select the **Access Token Type to be "JWT".**
* Under Authorizations, create **new authorization** by searching for the project name and **assign the "user" role** to the new service user
* Under Keys, **create a new key of type "JSON"** and note the key ID and **download** the key (JSON file).
* **Update the config** in `demo_project/service_user.py`
# Zitadel Setup Guide

This guide walks you through setting up Zitadel authentication for your FastAPI application using `fastapi-zitadel-auth`. It covers configuring:
- OAuth2 project settings
- API application for service authentication
- User Agent application for Swagger UI integration
- User and service user permissions

Follow these steps to enable secure authentication and API documentation through Swagger UI.

## Project Configuration
1. Create new project
2. Enable security features in General settings:
- "Assert Roles on Authentication"
- "Check authorization on Authentication"
3. Record the **project ID** (resource ID)
4. Create role (e.g., `user`) and assign to project

## API Application Setup
Create application with:
- Type: "API"
- Authentication: "JWT (Private Key JWT)"

## User Agent Application Setup
Create application with:
- Type: "User Agent"
- Authentication: "PKCE"

Configure token settings:
- Set "Auth Token Type" to "JWT"
- Enable "Add user roles to access token"
- Enable "User roles inside ID token"

Configure redirect URIs:
- Add `http://localhost:8001/oauth2-redirect` (or your FastAPI app URL + `/oauth2-redirect`)
- Development Mode: Enable for non-HTTPS redirects (development only)

Record the Client ID.

## User Setup
1. Create user account
2. Grant authorization:
- Search project
- Assign created role

## Service User Setup
1. Create service user with JWT access token type
2. Grant project authorization with required role
3. Generate JSON key:
- Create new key (type: "JSON")
- Download key file
4. Keep key file secure

To use this key in the demo app, update the path in `demo_project/service_user.py`.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fastapi-zitadel-auth"
version = "0.1.0" # change in src/fastapi_zitadel_auth/__init__.py as well
version = "0.1.1" # change in src/fastapi_zitadel_auth/__init__.py as well
description = "Zitadel authentication for FastAPI"
readme = "README.md"
authors = [
Expand Down
2 changes: 1 addition & 1 deletion src/fastapi_zitadel_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@

__all__ = ["ZitadelAuth", "AuthConfig"]

__version__ = "0.1.0"
__version__ = "0.1.1" # remember to update also in pyproject.toml
10 changes: 5 additions & 5 deletions src/fastapi_zitadel_auth/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class AuthConfig(BaseModel):

client_id: str = Field(..., description="OAuth2 client ID")
project_id: str = Field(..., description="Zitadel project ID")
base_url: AnyHttpUrl = Field(..., description="Zitadel instance URL")
zitadel_host: AnyHttpUrl = Field(..., description="Zitadel instance URL")
algorithm: str = Field(default="RS256", description="JWT signing algorithm")
scopes: dict[str, str] | None = Field(
default=None, description="OAuth2 scope descriptions"
Expand All @@ -18,22 +18,22 @@ class AuthConfig(BaseModel):
@property
def issuer(self) -> str:
"""Base URL without trailing slash for JWT issuer validation"""
return str(self.base_url).rstrip("/")
return str(self.zitadel_host).rstrip("/")

@computed_field # type: ignore[prop-decorator]
@property
def jwks_url(self) -> str:
"""JWKS endpoint URL"""
return f"{self.base_url}oauth/v2/keys"
return f"{self.zitadel_host}oauth/v2/keys"

@computed_field # type: ignore[prop-decorator]
@property
def authorization_url(self) -> str:
"""OAuth2 authorization endpoint URL"""
return f"{self.base_url}oauth/v2/authorize"
return f"{self.zitadel_host}oauth/v2/authorize"

@computed_field # type: ignore[prop-decorator]
@property
def token_url(self) -> str:
"""OAuth2 token endpoint URL"""
return f"{self.base_url}oauth/v2/token"
return f"{self.zitadel_host}oauth/v2/token"
2 changes: 1 addition & 1 deletion src/fastapi_zitadel_auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ class JWTClaims(BaseModel):
exp: int
iat: int
iss: str
nbf: int
sub: str
nbf: int | None = None
jti: str | None = None


Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def fastapi_app():
FastAPI app fixture
"""
auth_config = AuthConfig(
base_url="https://issuer.zitadel.cloud",
zitadel_host="https://issuer.zitadel.cloud",
client_id="123456789",
project_id="987654321",
algorithm="RS256",
Expand Down Expand Up @@ -67,7 +67,7 @@ def auth_config():
AuthConfig fixture
"""
return AuthConfig(
base_url="https://issuer.zitadel.cloud",
zitadel_host="https://issuer.zitadel.cloud",
client_id="123456789",
project_id="987654321",
algorithm="RS256",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def test_validate_scopes_project_id(project_id, claims, expected_error):
Test the _validate_scopes method with different project IDs
"""
config = AuthConfig(
base_url="https://issuer.zitadel.cloud",
zitadel_host="https://issuer.zitadel.cloud",
client_id="123456789",
project_id=project_id,
algorithm="RS256",
Expand Down
10 changes: 5 additions & 5 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def valid_config_data() -> dict:
return {
"client_id": "test-client-123",
"project_id": "proj-123",
"base_url": "https://auth.example.com/",
"zitadel_host": "https://auth.example.com/",
}


Expand All @@ -28,7 +28,7 @@ def test_valid_config(self, valid_config_data):
config = AuthConfig(**valid_config_data)
assert config.client_id == "test-client-123"
assert config.project_id == "proj-123"
assert str(config.base_url) == "https://auth.example.com/"
assert str(config.zitadel_host) == "https://auth.example.com/"
assert config.algorithm == "RS256" # default value
assert config.scopes is None # default value

Expand All @@ -38,7 +38,7 @@ def test_computed_issuer(self, valid_config_data):
assert config.issuer == "https://auth.example.com"

# Test without trailing slash
valid_config_data["base_url"] = "https://auth.example.com"
valid_config_data["zitadel_host"] = "https://auth.example.com"
config = AuthConfig(**valid_config_data)
assert config.issuer == "https://auth.example.com"

Expand Down Expand Up @@ -70,7 +70,7 @@ def test_custom_algorithm(self, valid_config_data):

def test_invalid_url(self, valid_config_data):
"""Test validation error for invalid URL"""
valid_config_data["base_url"] = "not-a-url"
valid_config_data["zitadel_host"] = "not-a-url"
with pytest.raises(ValidationError) as exc_info:
AuthConfig(**valid_config_data)
errors = exc_info.value.errors()
Expand All @@ -81,7 +81,7 @@ def test_missing_required_fields(self):
with pytest.raises(ValidationError) as exc_info:
AuthConfig()
errors = exc_info.value.errors()
required_fields = {"client_id", "project_id", "base_url"}
required_fields = {"client_id", "project_id", "zitadel_host"}
error_fields = {error["loc"][0] for error in errors}
assert required_fields.issubset(error_fields)

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit b01a855

Please sign in to comment.