diff --git a/cookbook/keycloak_oauth2_integration.py b/cookbook/keycloak_oauth2_integration.py new file mode 100644 index 00000000..91431009 --- /dev/null +++ b/cookbook/keycloak_oauth2_integration.py @@ -0,0 +1,279 @@ +""" +Keycloak OAuth2/OIDC Integration Example for HealthChain API + +This example demonstrates how to secure a HealthChain API with Keycloak +authentication and role-based access control. + +Prerequisites: +1. Keycloak server running (e.g., http://localhost:8080) +2. A realm configured in Keycloak (e.g., 'healthchain') +3. A client configured in the realm (e.g., 'healthchain-api') +4. Users with appropriate roles created + +For Keycloak setup, see: docs/cookbook/setup_keycloak_auth.md +""" + +import uvicorn +from fastapi import Depends + +from healthchain.gateway.api.app import HealthChainAPI +from healthchain.gateway.api.auth import ( + JWTAuthMiddleware, + OIDCConfig, + OIDCProvider, + get_current_user, + require_role, + require_roles, + require_scope, +) + + +# Keycloak Configuration +# Replace these values with your Keycloak setup +KEYCLOAK_CONFIG = OIDCConfig( + issuer="http://localhost:8080/realms/healthchain", + client_id="healthchain-api", + client_secret="your-client-secret-here", # Get from Keycloak client credentials + audience="healthchain-api", + algorithms=["RS256"], + verify_exp=True, + verify_aud=True, +) + + +async def setup_auth(app: HealthChainAPI) -> OIDCProvider: + """ + Setup OAuth2 authentication with Keycloak. + + Args: + app: HealthChain API instance + + Returns: + Initialized OIDC provider + """ + # Initialize OIDC provider + oidc_provider = OIDCProvider(KEYCLOAK_CONFIG) + await oidc_provider.initialize() + + # Add JWT authentication middleware + app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + exclude_paths=[ + "/", + "/docs", + "/openapi.json", + "/redoc", + "/health", + ], + optional_auth_paths=[ + "/api/public/*", + ], + ) + + return oidc_provider + + +# Create HealthChain API +app = HealthChainAPI( + title="HealthChain API with Keycloak Auth", + description="Secure healthcare data API with Keycloak authentication", + version="1.0.0", +) + + +# Public endpoint - no authentication required +@app.get("/") +async def root(): + """Public endpoint.""" + return { + "message": "HealthChain API with Keycloak Authentication", + "docs": "/docs", + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint - no authentication required.""" + return {"status": "healthy"} + + +# Protected endpoint - requires authentication +@app.get("/api/user/profile") +async def get_user_profile(user: dict = Depends(get_current_user)): + """ + Get current user profile. + + Requires: Valid JWT token + """ + if not user: + return {"message": "Not authenticated"} + + return { + "user_id": user.get("sub"), + "username": user.get("preferred_username"), + "email": user.get("email"), + "roles": user.get("realm_access", {}).get("roles", []), + } + + +# Role-based endpoints +@app.get("/api/patients") +async def list_patients(user: dict = Depends(require_role("healthcare_provider"))): + """ + List patients - requires 'healthcare_provider' role. + + Requires: JWT token with 'healthcare_provider' role + """ + return { + "message": "Patient list", + "user": user.get("preferred_username"), + "patients": [ + {"id": 1, "name": "John Doe"}, + {"id": 2, "name": "Jane Smith"}, + ], + } + + +@app.get("/api/patients/{patient_id}") +async def get_patient( + patient_id: int, + user: dict = Depends(require_roles(["doctor", "nurse"])), +): + """ + Get patient details - requires 'doctor' OR 'nurse' role. + + Requires: JWT token with either 'doctor' or 'nurse' role + """ + return { + "message": f"Patient {patient_id} details", + "user": user.get("preferred_username"), + "patient": { + "id": patient_id, + "name": "John Doe", + "dob": "1980-01-01", + }, + } + + +@app.post("/api/prescriptions") +async def create_prescription( + user: dict = Depends(require_role("doctor")), +): + """ + Create prescription - requires 'doctor' role. + + Requires: JWT token with 'doctor' role + """ + return { + "message": "Prescription created", + "doctor": user.get("preferred_username"), + "prescription_id": "RX123", + } + + +@app.get("/api/admin/users") +async def list_users(user: dict = Depends(require_role("admin"))): + """ + List all users - requires 'admin' role. + + Requires: JWT token with 'admin' role + """ + return { + "message": "User list", + "admin": user.get("preferred_username"), + "users": [ + {"id": 1, "username": "doctor1", "role": "doctor"}, + {"id": 2, "username": "nurse1", "role": "nurse"}, + ], + } + + +@app.get("/api/admin/audit") +async def get_audit_logs( + user: dict = Depends(require_roles(["admin", "auditor"], require_all=False)), +): + """ + Get audit logs - requires 'admin' OR 'auditor' role. + + Requires: JWT token with either 'admin' or 'auditor' role + """ + return { + "message": "Audit logs", + "user": user.get("preferred_username"), + "logs": [ + {"action": "login", "user": "doctor1", "timestamp": "2025-12-12T10:00:00Z"}, + { + "action": "access_patient", + "user": "nurse1", + "timestamp": "2025-12-12T10:05:00Z", + }, + ], + } + + +# Scope-based endpoint (FHIR-style) +@app.get("/fhir/Patient") +async def fhir_search_patients(user: dict = Depends(require_scope("patient/*.read"))): + """ + Search FHIR patients - requires 'patient/*.read' scope. + + Requires: JWT token with 'patient/*.read' scope + """ + return { + "resourceType": "Bundle", + "type": "searchset", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "id": "1", + "name": [{"family": "Doe", "given": ["John"]}], + } + } + ], + } + + +# Startup event to initialize authentication +@app.on_event("startup") +async def startup_event(): + """Initialize authentication on startup.""" + try: + oidc_provider = await setup_auth(app) + print("✅ Keycloak authentication initialized successfully") + print(f" Issuer: {oidc_provider.get_issuer()}") + print(f" JWKS URI: {oidc_provider.config.jwks_uri}") + except Exception as e: + print(f"❌ Failed to initialize authentication: {e}") + print(" The API will start but authentication will not work.") + + +if __name__ == "__main__": + print("=" * 60) + print("HealthChain API with Keycloak Authentication") + print("=" * 60) + print("\nStarting server...") + print("\nTo test the API:") + print("1. Get a token from Keycloak:") + print( + " curl -X POST http://localhost:8080/realms/healthchain/protocol/openid-connect/token \\" + ) + print(" -d 'client_id=healthchain-api' \\") + print(" -d 'client_secret=your-secret' \\") + print(" -d 'grant_type=password' \\") + print(" -d 'username=your-username' \\") + print(" -d 'password=your-password'") + print("\n2. Use the token to access protected endpoints:") + print(" curl http://localhost:8000/api/user/profile \\") + print(" -H 'Authorization: Bearer '") + print("\nAPI Documentation: http://localhost:8000/docs") + print("=" * 60) + print() + + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info", + ) diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md index 165624b6..b70c9a3f 100644 --- a/docs/cookbook/index.md +++ b/docs/cookbook/index.md @@ -25,6 +25,9 @@ Dive into real-world, production-ready examples to learn how to build interopera - 📝 **[Summarize Discharge Notes with CDS Hooks](./discharge_summarizer.md)** *Deploy a CDS Hooks-compliant service that listens for discharge events, auto-generates concise plain-language summaries, and delivers actionable clinical cards directly into the EHR workflow.* +- 🔐 **[OAuth2/OIDC Authentication with Keycloak](./oauth2_authentication.md)** + *Secure your HealthChain API with JWT token validation, role-based access control (RBAC), and OIDC integration. Includes complete Keycloak setup and examples with Auth0, Okta, and Azure AD.* + --- !!! info "What next?" diff --git a/docs/cookbook/oauth2_authentication.md b/docs/cookbook/oauth2_authentication.md new file mode 100644 index 00000000..40068b9d --- /dev/null +++ b/docs/cookbook/oauth2_authentication.md @@ -0,0 +1,372 @@ +# OAuth2/OIDC Authentication with HealthChain + +This guide demonstrates how to secure your HealthChain API with OAuth2 and OpenID Connect (OIDC) authentication using industry-standard identity providers like Keycloak, Auth0, or Okta. + +## Overview + +HealthChain provides built-in support for: + +- **JWT Token Validation**: Validate JWT tokens using JWKS or token introspection +- **OIDC Integration**: Support for any OIDC-compliant identity provider +- **Role-Based Access Control (RBAC)**: Protect endpoints with role requirements +- **Scope-Based Authorization**: OAuth2 scope validation for fine-grained access +- **Keycloak Reference Implementation**: Example with Keycloak as authorization server + +## Quick Start + +### 1. Install Dependencies + +The authentication module requires additional dependencies: + +```bash +pip install pyjwt[crypto] cryptography +``` + +### 2. Basic Setup + +```python +from healthchain.gateway.api.app import HealthChainAPI +from healthchain.gateway.api.auth import ( + JWTAuthMiddleware, + OIDCConfig, + OIDCProvider, +) + +# Create API +app = HealthChainAPI() + +# Configure OIDC provider +oidc_config = OIDCConfig( + issuer="https://your-keycloak.com/realms/healthchain", + client_id="healthchain-api", + client_secret="your-secret", + audience="healthchain-api" +) + +# Initialize and add authentication +async def setup_auth(): + oidc_provider = OIDCProvider(oidc_config) + await oidc_provider.initialize() + + app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + exclude_paths=["/docs", "/health"] + ) +``` + +## Keycloak Setup + +### Step 1: Install Keycloak + +Using Docker: + +```bash +docker run -p 8080:8080 \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:latest start-dev +``` + +### Step 2: Create Realm + +1. Access Keycloak at http://localhost:8080 +2. Login with admin credentials +3. Create a new realm called "healthchain" + +### Step 3: Create Client + +1. Navigate to Clients → Create client +2. Client ID: `healthchain-api` +3. Client authentication: ON +4. Valid redirect URIs: `http://localhost:8000/*` +5. Save and note the client secret from the Credentials tab + +### Step 4: Create Roles + +1. Navigate to Realm roles → Create role +2. Create roles: + - `doctor` + - `nurse` + - `admin` + - `healthcare_provider` + +### Step 5: Create Users + +1. Navigate to Users → Add user +2. Create users and assign roles under Role mapping + +### Step 6: Configure HealthChain + +```python +from healthchain.gateway.api.auth import OIDCConfig + +config = OIDCConfig( + issuer="http://localhost:8080/realms/healthchain", + client_id="healthchain-api", + client_secret="", + audience="healthchain-api" +) +``` + +## Authentication Patterns + +### Pattern 1: Require Authentication + +```python +from fastapi import Depends +from healthchain.gateway.api.auth import get_current_user + +@app.get("/api/profile") +async def get_profile(user: dict = Depends(get_current_user)): + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return {"user": user} +``` + +### Pattern 2: Role-Based Access + +```python +from healthchain.gateway.api.auth import require_role + +@app.get("/api/patients") +async def list_patients(user: dict = Depends(require_role("doctor"))): + return {"patients": [...]} +``` + +### Pattern 3: Multiple Roles (OR Logic) + +```python +from healthchain.gateway.api.auth import require_roles + +@app.get("/api/patient/{id}") +async def get_patient( + id: int, + user: dict = Depends(require_roles(["doctor", "nurse"])) +): + # User needs doctor OR nurse role + return {"patient": {...}} +``` + +### Pattern 4: Multiple Roles (AND Logic) + +```python +@app.post("/api/admin/audit") +async def create_audit( + user: dict = Depends(require_roles(["admin", "auditor"], require_all=True)) +): + # User needs both admin AND auditor roles + return {"status": "created"} +``` + +### Pattern 5: Scope-Based Access + +```python +from healthchain.gateway.api.auth import require_scope + +@app.get("/fhir/Patient") +async def search_patients(user: dict = Depends(require_scope("patient/*.read"))): + return {"resourceType": "Bundle", "entry": [...]} +``` + +## OIDC Provider Examples + +### Keycloak + +```python +config = OIDCConfig( + issuer="https://keycloak.example.com/realms/healthchain", + client_id="healthchain-api", + client_secret="your-secret" +) +``` + +### Auth0 + +```python +config = OIDCConfig( + issuer="https://your-tenant.auth0.com", + client_id="your-client-id", + client_secret="your-secret", + audience="https://api.healthchain.example.com" +) +``` + +### Okta + +```python +config = OIDCConfig( + issuer="https://your-domain.okta.com/oauth2/default", + client_id="your-client-id", + client_secret="your-secret" +) +``` + +### Azure AD + +```python +config = OIDCConfig( + issuer="https://login.microsoftonline.com/{tenant-id}/v2.0", + client_id="your-client-id", + client_secret="your-secret" +) +``` + +## Token Validation Methods + +### Method 1: JWKS (Default) + +Validates JWT tokens locally using public keys from JWKS endpoint: + +```python +app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + use_introspection=False # Default +) +``` + +**Pros**: Fast, no network call per request +**Cons**: Slightly delayed revocation + +### Method 2: Token Introspection + +Validates tokens by calling the introspection endpoint: + +```python +app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + use_introspection=True +) +``` + +**Pros**: Real-time revocation +**Cons**: Network call per request + +## Middleware Configuration + +### Exclude Paths + +```python +app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + exclude_paths=[ + "/", + "/docs", + "/openapi.json", + "/health", + "/public/*" # Wildcard support + ] +) +``` + +### Optional Authentication + +```python +app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + optional_auth_paths=[ + "/api/public/*" # Auth optional, user extracted if present + ] +) +``` + +## Testing + +### Get Token from Keycloak + +```bash +curl -X POST http://localhost:8080/realms/healthchain/protocol/openid-connect/token \ + -d 'client_id=healthchain-api' \ + -d 'client_secret=your-secret' \ + -d 'grant_type=password' \ + -d 'username=doctor1' \ + -d 'password=password' +``` + +### Use Token + +```bash +TOKEN="" + +curl http://localhost:8000/api/patients \ + -H "Authorization: Bearer $TOKEN" +``` + +## User Information Structure + +The `user` object contains JWT claims: + +```python +{ + "sub": "user-id", + "preferred_username": "doctor1", + "email": "doctor1@example.com", + "email_verified": True, + "realm_access": { + "roles": ["doctor", "healthcare_provider"] + }, + "scope": "openid profile email", + "exp": 1702389000, + "iat": 1702388700, + "iss": "http://localhost:8080/realms/healthchain" +} +``` + +### Extracting User Info + +```python +@app.get("/api/me") +async def get_me(user: dict = Depends(get_current_user)): + return { + "id": user.get("sub"), + "username": user.get("preferred_username"), + "email": user.get("email"), + "roles": user.get("realm_access", {}).get("roles", []) + } +``` + +## Security Best Practices + +1. **Use HTTPS in production**: Never use HTTP for authentication +2. **Rotate secrets**: Regularly rotate client secrets +3. **Validate audience**: Always set and verify the `audience` claim +4. **Short token expiration**: Use short-lived access tokens (5-15 minutes) +5. **Use refresh tokens**: For long-lived sessions +6. **Implement rate limiting**: Protect against brute force attacks +7. **Log authentication events**: Monitor for suspicious activity + +## Troubleshooting + +### Token Validation Fails + +1. Check issuer URL matches exactly (no trailing slash) +2. Verify JWKS endpoint is accessible +3. Ensure token hasn't expired +4. Check audience claim matches configuration + +### Role Check Fails + +1. Verify roles are in the JWT token +2. Check role claim path (realm_access.roles for Keycloak) +3. Ensure user has role assigned in identity provider + +### OIDC Discovery Fails + +1. Ensure `.well-known/openid-configuration` endpoint is accessible +2. Check network connectivity to identity provider +3. Verify issuer URL is correct + +## Complete Example + +See the full working example: [cookbook/keycloak_oauth2_integration.py](../../cookbook/keycloak_oauth2_integration.py) + +## Related Resources + +- [Keycloak Documentation](https://www.keycloak.org/documentation) +- [OpenID Connect Specification](https://openid.net/connect/) +- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) +- [JWT RFC 7519](https://tools.ietf.org/html/rfc7519) diff --git a/healthchain/gateway/api/auth/README.md b/healthchain/gateway/api/auth/README.md new file mode 100644 index 00000000..b5d016e4 --- /dev/null +++ b/healthchain/gateway/api/auth/README.md @@ -0,0 +1,84 @@ +# Authentication & Authorization Module + +This module provides OAuth2/OIDC authentication and role-based access control for HealthChain API. + +## Features + +- **JWT Token Validation**: Validate JWT tokens using JWKS endpoints +- **OIDC Integration**: Support for Keycloak, Auth0, Okta, Azure AD, and other OIDC providers +- **Token Introspection**: Alternative validation using OAuth2 introspection +- **Role-Based Access Control**: Protect endpoints with role requirements +- **Scope Validation**: OAuth2 scope-based authorization +- **Flexible Middleware**: Path-based exclusions and optional authentication + +## Quick Start + +```python +from healthchain.gateway.api.app import HealthChainAPI +from healthchain.gateway.api.auth import ( + JWTAuthMiddleware, + OIDCConfig, + OIDCProvider, + require_role, +) + +# Configure OIDC +config = OIDCConfig( + issuer="https://keycloak.example.com/realms/healthchain", + client_id="healthchain-api", + client_secret="your-secret", + audience="healthchain-api" +) + +# Create API +app = HealthChainAPI() + +# Setup auth +@app.on_event("startup") +async def setup_auth(): + oidc_provider = OIDCProvider(config) + await oidc_provider.initialize() + + app.add_middleware( + JWTAuthMiddleware, + oidc_provider=oidc_provider, + exclude_paths=["/docs", "/health"] + ) + +# Protect endpoints +@app.get("/api/patients") +async def list_patients(user: dict = Depends(require_role("doctor"))): + return {"patients": [...]} +``` + +## Module Structure + +- `oidc.py`: OIDC provider configuration and client +- `middleware.py`: JWT authentication middleware +- `dependencies.py`: FastAPI dependencies for RBAC +- `__init__.py`: Public API exports + +## Documentation + +See the complete guide: [docs/cookbook/oauth2_authentication.md](../../../docs/cookbook/oauth2_authentication.md) + +## Example + +Working example with Keycloak: [cookbook/keycloak_oauth2_integration.py](../../../cookbook/keycloak_oauth2_integration.py) + +## Supported Identity Providers + +- Keycloak (reference implementation) +- Auth0 +- Okta +- Azure AD +- Google Identity Platform +- Any OIDC-compliant provider + +## Dependencies + +```bash +pip install pyjwt[crypto] cryptography +``` + +These are included in HealthChain's main dependencies. diff --git a/healthchain/gateway/api/auth/__init__.py b/healthchain/gateway/api/auth/__init__.py new file mode 100644 index 00000000..63187922 --- /dev/null +++ b/healthchain/gateway/api/auth/__init__.py @@ -0,0 +1,25 @@ +""" +Authentication and authorization module for HealthChain API. + +This module provides JWT token validation, OIDC integration, +and role-based access control for securing API endpoints. +""" + +from healthchain.gateway.api.auth.middleware import JWTAuthMiddleware +from healthchain.gateway.api.auth.oidc import OIDCConfig, OIDCProvider +from healthchain.gateway.api.auth.dependencies import ( + get_current_user, + require_role, + require_roles, + require_scope, +) + +__all__ = [ + "JWTAuthMiddleware", + "OIDCConfig", + "OIDCProvider", + "get_current_user", + "require_role", + "require_roles", + "require_scope", +] diff --git a/healthchain/gateway/api/auth/dependencies.py b/healthchain/gateway/api/auth/dependencies.py new file mode 100644 index 00000000..2c31fa25 --- /dev/null +++ b/healthchain/gateway/api/auth/dependencies.py @@ -0,0 +1,337 @@ +""" +FastAPI dependencies for authentication and authorization. + +Provides reusable dependency functions for protecting endpoints +with role-based access control. +""" + +import logging +from typing import List, Optional, Set +from fastapi import Depends, HTTPException, Request, status + +logger = logging.getLogger(__name__) + + +async def get_current_user(request: Request) -> Optional[dict]: + """ + Get the current authenticated user from request state. + + This dependency extracts user information that was attached to the + request state by the JWT authentication middleware. + + Args: + request: The incoming request + + Returns: + User information dictionary or None if not authenticated + + Example: + ```python + @app.get("/protected") + async def protected_route(user: dict = Depends(get_current_user)): + return {"user": user} + ``` + """ + return getattr(request.state, "user", None) + + +async def require_authenticated_user( + user: Optional[dict] = Depends(get_current_user), +) -> dict: + """ + Require an authenticated user. + + This dependency ensures that a valid authenticated user is present, + raising an HTTP 401 error if not. + + Args: + user: User information from get_current_user dependency + + Returns: + User information dictionary + + Raises: + HTTPException: 401 if user is not authenticated + + Example: + ```python + @app.get("/protected") + async def protected_route(user: dict = Depends(require_authenticated_user)): + return {"user_id": user.get("sub")} + ``` + """ + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + + +def require_role(required_role: str): + """ + Create a dependency that requires a specific role. + + This factory function creates a FastAPI dependency that checks if the + authenticated user has the required role. Roles are typically extracted + from JWT claims like 'realm_access.roles' (Keycloak) or 'roles' (Auth0). + + Args: + required_role: The role name required to access the endpoint + + Returns: + FastAPI dependency function + + Example: + ```python + @app.get("/admin") + async def admin_route(user: dict = Depends(require_role("admin"))): + return {"message": "Admin access granted"} + ``` + """ + + async def role_checker(user: dict = Depends(require_authenticated_user)) -> dict: + roles = _extract_roles(user) + + if required_role not in roles: + logger.warning( + f"User {user.get('sub')} attempted to access resource requiring " + f"role '{required_role}' but only has roles: {roles}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Role '{required_role}' required", + ) + + return user + + return role_checker + + +def require_roles(required_roles: List[str], require_all: bool = False): + """ + Create a dependency that requires multiple roles. + + This factory function creates a FastAPI dependency that checks if the + authenticated user has the required roles. Can check for any role + (OR logic) or all roles (AND logic). + + Args: + required_roles: List of role names + require_all: If True, user must have all roles. If False, any role is sufficient. + + Returns: + FastAPI dependency function + + Example: + ```python + # User must have at least one of these roles + @app.get("/healthcare") + async def healthcare_route( + user: dict = Depends(require_roles(["doctor", "nurse"])) + ): + return {"message": "Healthcare access granted"} + + # User must have both roles + @app.get("/admin-healthcare") + async def admin_healthcare_route( + user: dict = Depends(require_roles(["admin", "doctor"], require_all=True)) + ): + return {"message": "Admin healthcare access granted"} + ``` + """ + + async def roles_checker(user: dict = Depends(require_authenticated_user)) -> dict: + user_roles = _extract_roles(user) + + if require_all: + # User must have ALL required roles + missing_roles = set(required_roles) - user_roles + if missing_roles: + logger.warning( + f"User {user.get('sub')} missing required roles: {missing_roles}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"All roles required: {required_roles}", + ) + else: + # User must have at least ONE required role + if not any(role in user_roles for role in required_roles): + logger.warning( + f"User {user.get('sub')} has no matching roles. " + f"Required: {required_roles}, Has: {user_roles}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"One of these roles required: {required_roles}", + ) + + return user + + return roles_checker + + +def require_scope(required_scope: str): + """ + Create a dependency that requires a specific OAuth2 scope. + + This factory function creates a FastAPI dependency that checks if the + access token has the required OAuth2 scope. + + Args: + required_scope: The scope required to access the endpoint + + Returns: + FastAPI dependency function + + Example: + ```python + @app.get("/patient/read") + async def read_patient(user: dict = Depends(require_scope("patient:read"))): + return {"message": "Patient read access granted"} + ``` + """ + + async def scope_checker(user: dict = Depends(require_authenticated_user)) -> dict: + scopes = _extract_scopes(user) + + if required_scope not in scopes: + logger.warning( + f"User {user.get('sub')} attempted to access resource requiring " + f"scope '{required_scope}' but only has scopes: {scopes}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Scope '{required_scope}' required", + ) + + return user + + return scope_checker + + +def require_scopes(required_scopes: List[str], require_all: bool = True): + """ + Create a dependency that requires multiple OAuth2 scopes. + + Args: + required_scopes: List of scope names + require_all: If True, token must have all scopes. If False, any scope is sufficient. + + Returns: + FastAPI dependency function + + Example: + ```python + @app.post("/patient/write") + async def write_patient( + user: dict = Depends(require_scopes(["patient:read", "patient:write"], require_all=True)) + ): + return {"message": "Patient write access granted"} + ``` + """ + + async def scopes_checker(user: dict = Depends(require_authenticated_user)) -> dict: + user_scopes = _extract_scopes(user) + + if require_all: + missing_scopes = set(required_scopes) - user_scopes + if missing_scopes: + logger.warning( + f"User {user.get('sub')} missing required scopes: {missing_scopes}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"All scopes required: {required_scopes}", + ) + else: + if not any(scope in user_scopes for scope in required_scopes): + logger.warning( + f"User {user.get('sub')} has no matching scopes. " + f"Required: {required_scopes}, Has: {user_scopes}" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"One of these scopes required: {required_scopes}", + ) + + return user + + return scopes_checker + + +def _extract_roles(user: dict) -> Set[str]: + """ + Extract roles from user token claims. + + Supports multiple common role claim formats: + - Keycloak: realm_access.roles, resource_access..roles + - Auth0: roles or permissions + - Generic: roles + + Args: + user: User information dictionary from JWT token + + Returns: + Set of role names + """ + roles = set() + + # Standard 'roles' claim + if "roles" in user: + if isinstance(user["roles"], list): + roles.update(user["roles"]) + elif isinstance(user["roles"], str): + roles.add(user["roles"]) + + # Keycloak realm_access.roles + if "realm_access" in user and isinstance(user["realm_access"], dict): + realm_roles = user["realm_access"].get("roles", []) + if isinstance(realm_roles, list): + roles.update(realm_roles) + + # Keycloak resource_access..roles + if "resource_access" in user and isinstance(user["resource_access"], dict): + for client, access in user["resource_access"].items(): + if isinstance(access, dict) and "roles" in access: + client_roles = access["roles"] + if isinstance(client_roles, list): + roles.update(client_roles) + + # Auth0 permissions as roles + if "permissions" in user: + if isinstance(user["permissions"], list): + roles.update(user["permissions"]) + + return roles + + +def _extract_scopes(user: dict) -> Set[str]: + """ + Extract OAuth2 scopes from user token claims. + + Args: + user: User information dictionary from JWT token + + Returns: + Set of scope names + """ + scopes = set() + + # Standard 'scope' claim (space-separated string) + if "scope" in user: + if isinstance(user["scope"], str): + scopes.update(user["scope"].split()) + elif isinstance(user["scope"], list): + scopes.update(user["scope"]) + + # Alternative 'scopes' claim + if "scopes" in user: + if isinstance(user["scopes"], list): + scopes.update(user["scopes"]) + elif isinstance(user["scopes"], str): + scopes.update(user["scopes"].split()) + + return scopes diff --git a/healthchain/gateway/api/auth/middleware.py b/healthchain/gateway/api/auth/middleware.py new file mode 100644 index 00000000..4f1a1877 --- /dev/null +++ b/healthchain/gateway/api/auth/middleware.py @@ -0,0 +1,285 @@ +""" +JWT authentication middleware for FastAPI. + +Provides token validation, user extraction, and request authentication. +""" + +import logging +from typing import Optional, Callable, List +from fastapi import Request, HTTPException, status +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response, JSONResponse +import jwt + +try: + from jwt import PyJWKClient +except ImportError: + # PyJWKClient is available in PyJWT 2.0+ + PyJWKClient = None + +from healthchain.gateway.api.auth.oidc import OIDCProvider + +logger = logging.getLogger(__name__) + + +class JWTAuthMiddleware(BaseHTTPMiddleware): + """ + FastAPI middleware for JWT token validation. + + This middleware validates JWT tokens on incoming requests and attaches + user information to the request state. It supports OIDC-compliant + providers and can validate tokens using JWKS or token introspection. + + Example: + ```python + from healthchain.gateway.api.auth import JWTAuthMiddleware, OIDCConfig + + oidc_config = OIDCConfig( + issuer="https://keycloak.example.com/realms/healthchain", + client_id="healthchain-api", + audience="healthchain-api" + ) + + app.add_middleware( + JWTAuthMiddleware, + oidc_config=oidc_config, + exclude_paths=["/docs", "/openapi.json", "/health"] + ) + ``` + """ + + def __init__( + self, + app, + oidc_provider: Optional[OIDCProvider] = None, + exclude_paths: Optional[List[str]] = None, + optional_auth_paths: Optional[List[str]] = None, + use_introspection: bool = False, + ): + """ + Initialize JWT authentication middleware. + + Args: + app: FastAPI application instance + oidc_provider: Initialized OIDC provider instance + exclude_paths: List of paths to exclude from authentication + optional_auth_paths: List of paths where auth is optional + use_introspection: Use token introspection instead of local JWT validation + """ + super().__init__(app) + self.oidc_provider = oidc_provider + self.exclude_paths = set(exclude_paths or []) + self.optional_auth_paths = set(optional_auth_paths or []) + self.use_introspection = use_introspection + self._jwks_client = None # Will be initialized as PyJWKClient if available + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + """ + Process the request and validate JWT token if present. + + Args: + request: The incoming request + call_next: The next middleware or route handler + + Returns: + Response from the next handler + """ + # Check if path is excluded + if self._is_excluded_path(request.url.path): + return await call_next(request) + + # Check if authentication is optional for this path + optional = self._is_optional_auth_path(request.url.path) + + try: + # Extract token from request + token = self._extract_token(request) + + if not token: + if optional: + # Continue without authentication + request.state.user = None + return await call_next(request) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Validate token and extract user info + user_info = await self._validate_token(token) + + # Attach user info to request state + request.state.user = user_info + + # Continue processing + response = await call_next(request) + return response + + except HTTPException: + raise + except Exception as e: + logger.error(f"Authentication error: {e}") + if optional: + request.state.user = None + return await call_next(request) + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Invalid authentication credentials"}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + def _is_excluded_path(self, path: str) -> bool: + """ + Check if the path is excluded from authentication. + + Args: + path: Request path + + Returns: + True if path is excluded + """ + # Exact match + if path in self.exclude_paths: + return True + + # Prefix match (for paths ending with *) + for excluded in self.exclude_paths: + if excluded.endswith("*") and path.startswith(excluded[:-1]): + return True + + return False + + def _is_optional_auth_path(self, path: str) -> bool: + """ + Check if authentication is optional for this path. + + Args: + path: Request path + + Returns: + True if authentication is optional + """ + if path in self.optional_auth_paths: + return True + + for optional in self.optional_auth_paths: + if optional.endswith("*") and path.startswith(optional[:-1]): + return True + + return False + + def _extract_token(self, request: Request) -> Optional[str]: + """ + Extract JWT token from Authorization header. + + Args: + request: The incoming request + + Returns: + Token string or None if not found + """ + auth_header = request.headers.get("Authorization") + if not auth_header: + return None + + parts = auth_header.split() + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + + return parts[1] + + async def _validate_token(self, token: str) -> dict: + """ + Validate JWT token and extract user information. + + Args: + token: JWT token string + + Returns: + Dictionary containing user information + + Raises: + HTTPException: If token is invalid + """ + if not self.oidc_provider: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OIDC provider not configured", + ) + + try: + if self.use_introspection: + # Use token introspection + user_info = await self.oidc_provider.introspect_token(token) + else: + # Validate JWT locally + user_info = await self._validate_jwt(token) + + return user_info + + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + headers={"WWW-Authenticate": "Bearer"}, + ) + except jwt.InvalidTokenError as e: + logger.error(f"Invalid token: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except Exception as e: + logger.error(f"Token validation error: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token validation failed", + headers={"WWW-Authenticate": "Bearer"}, + ) + + async def _validate_jwt(self, token: str) -> dict: + """ + Validate JWT token locally using JWKS. + + Args: + token: JWT token string + + Returns: + Decoded token payload + """ + config = self.oidc_provider.config + + # Initialize JWKS client if needed and available + if not self._jwks_client and config.jwks_uri and PyJWKClient is not None: + self._jwks_client = PyJWKClient(config.jwks_uri) + + # Get signing key + if self._jwks_client: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + key = signing_key.key + else: + # Fallback to fetching JWKS manually + jwks = await self.oidc_provider.get_jwks() + # This is a simplified approach; in production you'd need to + # match the key ID from the token header + key = jwks + + # Decode and validate token + payload = jwt.decode( + token, + key, + algorithms=config.algorithms, + audience=config.audience if config.verify_aud else None, + issuer=config.issuer if config.verify_iss else None, + options={ + "verify_exp": config.verify_exp, + "verify_aud": config.verify_aud, + "verify_iss": config.verify_iss, + }, + leeway=config.leeway, + ) + + return payload diff --git a/healthchain/gateway/api/auth/oidc.py b/healthchain/gateway/api/auth/oidc.py new file mode 100644 index 00000000..30e297fc --- /dev/null +++ b/healthchain/gateway/api/auth/oidc.py @@ -0,0 +1,261 @@ +""" +OIDC (OpenID Connect) provider configuration and integration. + +Supports OIDC-compliant providers like Keycloak, Auth0, Okta, etc. +""" + +import logging +from typing import Dict, List, Optional, Any +from pydantic import BaseModel, Field, field_validator +import httpx +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class OIDCConfig(BaseModel): + """ + Configuration for OIDC provider. + + This configuration supports standard OIDC-compliant identity providers + including Keycloak, Auth0, Okta, Azure AD, and others. + + Example: + ```python + config = OIDCConfig( + issuer="https://keycloak.example.com/realms/healthchain", + client_id="healthchain-api", + client_secret="your-secret", + audience="healthchain-api" + ) + ``` + """ + + issuer: str = Field( + ..., + description="OIDC issuer URL (e.g., https://keycloak.example.com/realms/myrealm)", + ) + client_id: str = Field(..., description="OAuth2 client ID") + client_secret: Optional[str] = Field( + None, description="OAuth2 client secret (for token introspection)" + ) + audience: Optional[str] = Field( + None, description="Expected audience claim in JWT tokens" + ) + jwks_uri: Optional[str] = Field( + None, description="JWKS endpoint URL (auto-discovered if not provided)" + ) + token_introspection_uri: Optional[str] = Field( + None, + description="Token introspection endpoint (auto-discovered if not provided)", + ) + algorithms: List[str] = Field( + default=["RS256"], description="Allowed JWT signing algorithms" + ) + verify_exp: bool = Field(default=True, description="Verify token expiration") + verify_aud: bool = Field(default=True, description="Verify audience claim") + verify_iss: bool = Field(default=True, description="Verify issuer claim") + leeway: int = Field( + default=0, description="Time leeway in seconds for token validation" + ) + cache_jwks: bool = Field(default=True, description="Cache JWKS keys") + jwks_cache_ttl: int = Field(default=3600, description="JWKS cache TTL in seconds") + + @field_validator("issuer") + @classmethod + def validate_issuer(cls, v: str) -> str: + """Ensure issuer URL doesn't end with a slash.""" + return v.rstrip("/") + + +class OIDCProvider: + """ + OIDC provider client for token validation and introspection. + + This class handles communication with OIDC-compliant identity providers, + including fetching JWKS keys, introspecting tokens, and performing + discovery of OIDC endpoints. + + Example: + ```python + provider = OIDCProvider(config) + await provider.initialize() + + # Validate a JWT token + user_info = await provider.validate_token(token) + ``` + """ + + def __init__(self, config: OIDCConfig): + """ + Initialize OIDC provider. + + Args: + config: OIDC configuration + """ + self.config = config + self._jwks: Optional[Dict[str, Any]] = None + self._jwks_cache_time: Optional[datetime] = None + self._well_known_config: Optional[Dict[str, Any]] = None + self._client: Optional[httpx.AsyncClient] = None + + async def initialize(self) -> None: + """ + Initialize the OIDC provider by discovering endpoints. + + This method fetches the OIDC configuration from the well-known + endpoint and sets up the necessary URIs. + """ + self._client = httpx.AsyncClient(timeout=30.0) + + try: + # Discover OIDC configuration + await self._discover_configuration() + + # Fetch JWKS if not using introspection + if self.config.cache_jwks: + await self._fetch_jwks() + + logger.info(f"OIDC provider initialized: {self.config.issuer}") + + except Exception as e: + logger.error(f"Failed to initialize OIDC provider: {e}") + raise + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client: + await self._client.aclose() + + async def _discover_configuration(self) -> None: + """ + Discover OIDC configuration from well-known endpoint. + + This implements the OIDC Discovery specification: + https://openid.net/specs/openid-connect-discovery-1_0.html + """ + well_known_url = f"{self.config.issuer}/.well-known/openid-configuration" + + try: + response = await self._client.get(well_known_url) + response.raise_for_status() + self._well_known_config = response.json() + + # Set discovered endpoints if not explicitly configured + if not self.config.jwks_uri: + self.config.jwks_uri = self._well_known_config.get("jwks_uri") + + if not self.config.token_introspection_uri: + self.config.token_introspection_uri = self._well_known_config.get( + "introspection_endpoint" + ) + + logger.debug(f"OIDC configuration discovered from {well_known_url}") + + except httpx.HTTPError as e: + logger.error(f"Failed to discover OIDC configuration: {e}") + raise ValueError( + f"Could not discover OIDC configuration from {well_known_url}. " + "Ensure the issuer URL is correct and the provider is accessible." + ) + + async def _fetch_jwks(self, force: bool = False) -> Dict[str, Any]: + """ + Fetch JWKS (JSON Web Key Set) from the provider. + + Args: + force: Force refresh even if cached + + Returns: + JWKS dictionary + """ + # Check cache + if ( + not force + and self._jwks + and self._jwks_cache_time + and self.config.cache_jwks + ): + age = (datetime.now() - self._jwks_cache_time).total_seconds() + if age < self.config.jwks_cache_ttl: + return self._jwks + + if not self.config.jwks_uri: + raise ValueError("JWKS URI not configured or discovered") + + try: + response = await self._client.get(self.config.jwks_uri) + response.raise_for_status() + self._jwks = response.json() + self._jwks_cache_time = datetime.now() + + logger.debug(f"JWKS fetched from {self.config.jwks_uri}") + return self._jwks + + except httpx.HTTPError as e: + logger.error(f"Failed to fetch JWKS: {e}") + raise ValueError(f"Could not fetch JWKS from {self.config.jwks_uri}") + + async def get_jwks(self) -> Dict[str, Any]: + """ + Get JWKS, fetching if necessary. + + Returns: + JWKS dictionary + """ + if not self._jwks: + await self._fetch_jwks() + return self._jwks + + async def introspect_token(self, token: str) -> Dict[str, Any]: + """ + Introspect a token using the OIDC introspection endpoint. + + This is useful for opaque tokens or as a fallback for JWT validation. + + Args: + token: The access token to introspect + + Returns: + Token introspection response + + Raises: + ValueError: If introspection endpoint is not configured + httpx.HTTPError: If introspection request fails + """ + if not self.config.token_introspection_uri: + raise ValueError("Token introspection endpoint not configured") + + if not self.config.client_secret: + raise ValueError("Client secret required for token introspection") + + try: + response = await self._client.post( + self.config.token_introspection_uri, + auth=(self.config.client_id, self.config.client_secret), + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + result = response.json() + + if not result.get("active"): + raise ValueError("Token is not active") + + return result + + except httpx.HTTPError as e: + logger.error(f"Token introspection failed: {e}") + raise + + def get_issuer(self) -> str: + """Get the configured issuer.""" + return self.config.issuer + + def get_algorithms(self) -> List[str]: + """Get allowed algorithms.""" + return self.config.algorithms + + def get_audience(self) -> Optional[str]: + """Get expected audience.""" + return self.config.audience diff --git a/pyproject.toml b/pyproject.toml index 77f30c4b..b0c67848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ dependencies = [ "fastapi-events>=0.12.2,<0.13", "jwt>=1.3.1,<2", "pyyaml>=6.0.3,<7", + "pyjwt[crypto]>=2.8.0,<3", + "cryptography>=41.0.0,<44", ] include = [ "healthchain/templates/*", diff --git a/tests/gateway/test_oidc_auth.py b/tests/gateway/test_oidc_auth.py new file mode 100644 index 00000000..6ccff40f --- /dev/null +++ b/tests/gateway/test_oidc_auth.py @@ -0,0 +1,199 @@ +""" +Tests for OAuth2/OIDC authentication middleware and RBAC. + +These tests verify JWT validation, OIDC integration, and role-based access control. +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from fastapi import FastAPI, Depends +from fastapi.testclient import TestClient + +from healthchain.gateway.api.auth import ( + OIDCConfig, + OIDCProvider, + JWTAuthMiddleware, + get_current_user, + require_role, +) + + +# Test fixtures +@pytest.fixture +def oidc_config(): + """Create test OIDC configuration.""" + return OIDCConfig( + issuer="https://test.example.com/realms/test", + client_id="test-client", + client_secret="test-secret", + audience="test-api", + ) + + +@pytest.fixture +def mock_jwks(): + """Mock JWKS response.""" + return { + "keys": [ + { + "kty": "RSA", + "kid": "test-key-1", + "use": "sig", + "alg": "RS256", + "n": "test-modulus", + "e": "AQAB", + } + ] + } + + +@pytest.fixture +def mock_well_known(): + """Mock OIDC discovery response.""" + return { + "issuer": "https://test.example.com/realms/test", + "jwks_uri": "https://test.example.com/realms/test/protocol/openid-connect/certs", + "introspection_endpoint": "https://test.example.com/realms/test/protocol/openid-connect/token/introspect", + } + + +class TestOIDCConfig: + """Test OIDC configuration.""" + + def test_oidc_config_creation(self): + """Test creating OIDC config.""" + config = OIDCConfig( + issuer="https://keycloak.example.com/realms/test", + client_id="test-client", + audience="test-api", + ) + assert config.issuer == "https://keycloak.example.com/realms/test" + assert config.client_id == "test-client" + assert config.algorithms == ["RS256"] + + def test_issuer_trailing_slash_removed(self): + """Test that trailing slash is removed from issuer.""" + config = OIDCConfig( + issuer="https://keycloak.example.com/realms/test/", + client_id="test-client", + ) + assert config.issuer == "https://keycloak.example.com/realms/test" + + +class TestOIDCProvider: + """Test OIDC provider functionality.""" + + @pytest.mark.asyncio + async def test_provider_initialization(self, oidc_config, mock_well_known): + """Test OIDC provider initialization.""" + provider = OIDCProvider(oidc_config) + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_client.return_value = mock_instance + + # Mock well-known endpoint + well_known_response = Mock() + well_known_response.json.return_value = mock_well_known + well_known_response.raise_for_status = Mock() + mock_instance.get.return_value = well_known_response + + await provider.initialize() + + assert provider.config.jwks_uri == mock_well_known["jwks_uri"] + assert ( + provider.config.token_introspection_uri + == mock_well_known["introspection_endpoint"] + ) + + +class TestJWTAuthMiddleware: + """Test JWT authentication middleware.""" + + def test_excluded_path(self): + """Test path exclusion logic.""" + app = FastAPI() + middleware = JWTAuthMiddleware( + app=app, + oidc_provider=None, + exclude_paths=["/docs", "/health", "/api/public/*"], + ) + + assert middleware._is_excluded_path("/docs") + assert middleware._is_excluded_path("/health") + assert middleware._is_excluded_path("/api/public/test") + assert not middleware._is_excluded_path("/api/private") + + def test_extract_token(self): + """Test token extraction from Authorization header.""" + app = FastAPI() + middleware = JWTAuthMiddleware(app=app, oidc_provider=None) + + request = Mock() + request.headers.get.return_value = "Bearer test-token" + + token = middleware._extract_token(request) + assert token == "test-token" + + +class TestRoleDependencies: + """Test role-based access control dependencies.""" + + def test_extract_roles_keycloak(self): + """Test extracting roles from Keycloak format.""" + from healthchain.gateway.api.auth.dependencies import _extract_roles + + user = { + "realm_access": {"roles": ["doctor", "admin"]}, + "resource_access": { + "healthchain-api": {"roles": ["api-user"]}, + }, + } + roles = _extract_roles(user) + assert "doctor" in roles + assert "admin" in roles + assert "api-user" in roles + + +class TestIntegration: + """Integration tests for authentication flow.""" + + def test_protected_endpoint_with_role(self): + """Test accessing protected endpoint with correct role.""" + app = FastAPI() + + @app.get("/api/test") + async def test_endpoint(user: dict = Depends(require_role("doctor"))): + return {"message": "success"} + + def override_get_user(): + return { + "sub": "user123", + "realm_access": {"roles": ["doctor"]}, + } + + app.dependency_overrides[get_current_user] = override_get_user + + client = TestClient(app) + response = client.get("/api/test") + assert response.status_code == 200 + + def test_protected_endpoint_without_role(self): + """Test accessing protected endpoint without correct role.""" + app = FastAPI() + + @app.get("/api/test") + async def test_endpoint(user: dict = Depends(require_role("admin"))): + return {"message": "success"} + + def override_get_user(): + return { + "sub": "user123", + "realm_access": {"roles": ["doctor"]}, + } + + app.dependency_overrides[get_current_user] = override_get_user + + client = TestClient(app) + response = client.get("/api/test") + assert response.status_code == 403 diff --git a/uv.lock b/uv.lock index b411f896..09538419 100644 --- a/uv.lock +++ b/uv.lock @@ -254,52 +254,35 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "43.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989, upload-time = "2024-10-18T15:58:32.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303, upload-time = "2024-10-18T15:57:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905, upload-time = "2024-10-18T15:57:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271, upload-time = "2024-10-18T15:57:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606, upload-time = "2024-10-18T15:57:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484, upload-time = "2024-10-18T15:57:45.434Z" }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131, upload-time = "2024-10-18T15:57:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647, upload-time = "2024-10-18T15:57:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873, upload-time = "2024-10-18T15:57:51.822Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039, upload-time = "2024-10-18T15:57:54.426Z" }, + { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984, upload-time = "2024-10-18T15:57:56.174Z" }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968, upload-time = "2024-10-18T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754, upload-time = "2024-10-18T15:58:00.683Z" }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458, upload-time = "2024-10-18T15:58:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220, upload-time = "2024-10-18T15:58:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898, upload-time = "2024-10-18T15:58:06.113Z" }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592, upload-time = "2024-10-18T15:58:08.673Z" }, + { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145, upload-time = "2024-10-18T15:58:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026, upload-time = "2024-10-18T15:58:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/6f/db/d8b8a039483f25fc3b70c90bc8f3e1d4497a99358d610c5067bf3bd4f0af/cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c", size = 3144545, upload-time = "2024-10-18T15:58:13.572Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/116edd5f8ec23b2dc879f7a42443e073cdad22950d3c8ee834e3b8124543/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3", size = 3679828, upload-time = "2024-10-18T15:58:15.254Z" }, + { url = "https://files.pythonhosted.org/packages/d8/32/1e1d78b316aa22c0ba6493cc271c1c309969e5aa5c22c830a1d7ce3471e6/cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83", size = 3908132, upload-time = "2024-10-18T15:58:16.943Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/cd2c13be3332e7af3cdf16154147952d39075b9f61ea5e6b5241bf4bf436/cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7", size = 2988811, upload-time = "2024-10-18T15:58:19.674Z" }, ] [[package]] @@ -498,6 +481,7 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "colorama" }, + { name = "cryptography" }, { name = "eval-type-backport" }, { name = "faker" }, { name = "fastapi" }, @@ -510,6 +494,7 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-liquid" }, { name = "pyyaml" }, { name = "regex" }, @@ -539,6 +524,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "colorama", specifier = ">=0.4.6,<0.5" }, + { name = "cryptography", specifier = ">=41.0.0,<44" }, { name = "eval-type-backport", specifier = ">=0.1.0,<0.2" }, { name = "faker", specifier = ">=25.1.0,<26" }, { name = "fastapi", specifier = ">=0.115.3,<0.116" }, @@ -551,6 +537,7 @@ requires-dist = [ { name = "numpy", specifier = "<2.0.0" }, { name = "pandas", specifier = ">=1.0.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.0.0,<2.11.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0,<3" }, { name = "python-liquid", specifier = ">=1.13.0,<2" }, { name = "pyyaml", specifier = ">=6.0.3,<7" }, { name = "regex", specifier = "!=2019.12.17" }, @@ -1345,6 +1332,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.17.2"