diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index 1f1f9e4a..d52b407b 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,11 +1,29 @@ - + sqlite.xerial true org.sqlite.JDBC jdbc:sqlite:$PROJECT_DIR$/sqlite.db + + + + $ProjectFileDir$ + + + file://$APPLICATION_CONFIG_DIR$/jdbc-drivers/Xerial SQLiteJDBC/3.39.2/sqlite-jdbc-3.39.2.jar + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/auth/kratos/db.sqlite + + + $ProjectFileDir$ diff --git a/README.md b/README.md index 7a42f989..ec7bbe72 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This template provides out of the box some commonly used functionalities: * Async tasks execution using [Dramatiq](https://dramatiq.io/index.html) * Repository pattern for databases using [SQLAlchemy](https://www.sqlalchemy.org/) and [SQLAlchemy bind manager](https://febus982.github.io/sqlalchemy-bind-manager/stable/) * Database migrations using [Alembic](https://alembic.sqlalchemy.org/en/latest/) (configured supporting both sync and async SQLAlchemy engines) +* Authentication and Identity Provider using [ORY Zero Trust architecture](https://www.ory.sh/docs/kratos/guides/zero-trust-iap-proxy-identity-access-proxy) * [TODO] Producer and consumer to emit and consume events using [CloudEvents](https://cloudevents.io/) format on [Confluent Kafka](https://docs.confluent.io/kafka-clients/python/current/overview.html) ## Documentation diff --git a/auth_volumes/kratos/.gitignore b/auth_volumes/kratos/.gitignore new file mode 100644 index 00000000..2fa69c24 --- /dev/null +++ b/auth_volumes/kratos/.gitignore @@ -0,0 +1 @@ +db.sqlite diff --git a/auth_volumes/kratos/identity.schema.json b/auth_volumes/kratos/identity.schema.json new file mode 100644 index 00000000..1a137875 --- /dev/null +++ b/auth_volumes/kratos/identity.schema.json @@ -0,0 +1,49 @@ +{ + "$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Person", + "type": "object", + "properties": { + "traits": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "title": "E-Mail", + "minLength": 3, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + } + }, + "verification": { + "via": "email" + }, + "recovery": { + "via": "email" + } + } + }, + "name": { + "type": "object", + "properties": { + "first": { + "title": "First Name", + "type": "string" + }, + "last": { + "title": "Last Name", + "type": "string" + } + } + } + }, + "required": [ + "email" + ], + "additionalProperties": false + } + } +} diff --git a/auth_volumes/kratos/kratos.yml b/auth_volumes/kratos/kratos.yml new file mode 100644 index 00000000..ad9d1f04 --- /dev/null +++ b/auth_volumes/kratos/kratos.yml @@ -0,0 +1,120 @@ +serve: + public: + # This URL has to match the one in oathkeeper rules config + # we'll probably want to remove references to ory and kratos + base_url: http://127.0.0.1:8080/.ory/kratos/public/ + # We're proxying the requests through oathkeeper, need CORS + cors: + enabled: true + allowed_origins: + - http://127.0.0.1:8080 + allowed_methods: + - POST + - GET + - PUT + - PATCH + - DELETE + allowed_headers: + - Authorization + - Cookie + - Content-Type + exposed_headers: + - Content-Type + - Set-Cookie + admin: + # This is the internal URL, we'll be accessing using docker network + # mainly to get the JWKS endpoint and do token validation + base_url: http://kratos:4434/ + +selfservice: + # URLs are using the Oathkeeper + default_browser_return_url: http://127.0.0.1:8080/ + allowed_return_urls: + - http://127.0.0.1:8080 + - http://localhost:19006/Callback + - exp://localhost:8081/--/Callback + + methods: + password: + enabled: true +# totp: +# config: +# issuer: Kratos +# enabled: true +# lookup_secret: +# enabled: true +# link: +# enabled: true +# code: +# enabled: true + + flows: + error: + ui_url: http://127.0.0.1:8080/error + + settings: + ui_url: http://127.0.0.1:8080/settings + privileged_session_max_age: 15m + required_aal: highest_available + + # If we enable recovery or verification we need also + # MailSlurper in the docker-compose file + recovery: + enabled: false + ui_url: http://127.0.0.1:8080/recovery + use: code + verification: + enabled: false + ui_url: http://127.0.0.1:8080/verification + use: code + after: + default_browser_return_url: http://127.0.0.1:8080/ + + logout: + after: + default_browser_return_url: http://127.0.0.1:8080/login + + login: + ui_url: http://127.0.0.1:8080/login + lifespan: 10m + + registration: + lifespan: 10m + ui_url: http://127.0.0.1:8080/registration + after: + password: + hooks: + - hook: session +# - hook: show_verification_ui + +log: + level: info + format: text + leak_sensitive_values: true + +secrets: + cookie: + - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE + cipher: + - 32-LONG-SECRET-NOT-SECURE-AT-ALL + +ciphers: + algorithm: xchacha20-poly1305 + +hashers: + algorithm: bcrypt + bcrypt: + cost: 8 + +identity: + default_schema_id: default + schemas: + - id: default + url: file:///etc/config/kratos/identity.schema.json + +courier: + smtp: + connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true + +feature_flags: + use_continue_with_transitions: true diff --git a/auth_volumes/oathkeeper/access-rules.yml b/auth_volumes/oathkeeper/access-rules.yml new file mode 100644 index 00000000..34748da7 --- /dev/null +++ b/auth_volumes/oathkeeper/access-rules.yml @@ -0,0 +1,101 @@ +# Kratos public API for authorized and unauthorized traffic +- id: "ory:kratos:public" + upstream: + preserve_host: true + url: "http://kratos:4433" + strip_path: /.ory/kratos/public + match: + # This URL has to match serve.public.base_url in kratos config + # we'll probably want to remove references to ory and kratos + url: "http://127.0.0.1:8080/.ory/kratos/public/<**>" + methods: + - GET + - POST + - PUT + - DELETE + - PATCH + authenticators: + - handler: noop + authorizer: + handler: allow + mutators: + - handler: noop + +# UI Access for anonymous traffic (Home page) +- id: "ory:auth-ui:anonymous" + upstream: + preserve_host: true + url: "http://auth-ui:3000" + match: + url: "http://127.0.0.1:8080/" + methods: + - GET + authenticators: + - handler: anonymous + authorizer: + handler: allow + mutators: + - handler: noop + +# UI Access for anonymous traffic (Other pages) +- id: "ory:auth-ui-home:anonymous" + upstream: + preserve_host: true + url: "http://auth-ui:3000" + match: + url: "http://127.0.0.1:8080/<{registration,welcome,recovery,verification,login,error,health/{alive,ready},**.css,**.js,**.png,**.svg,**.woff*}>" + methods: + - GET + authenticators: + - handler: anonymous + authorizer: + handler: allow + mutators: + - handler: noop + +# UI Access for logged-in only pages +- id: "ory:kratos-selfservice-ui-node:protected" + upstream: + preserve_host: true + url: "http://auth-ui:3000" + match: + url: "http://127.0.0.1:8080/<{sessions,settings}>" + methods: + - GET + authenticators: + - handler: cookie_session + authorizer: + handler: allow + mutators: + - handler: id_token + errors: + - handler: redirect + config: + to: http://127.0.0.1:8080/login + +# Dev container access to protected /hello endpoint +- id: "http_app:protected" + upstream: + preserve_host: true + url: "http://dev:8000" + match: + url: "http://127.0.0.1:8080/hello<{,/,/**}>" + methods: + - GET + authenticators: + # Get opaque token from cookie + - handler: cookie_session + + # Or from bearer token + # Note this is not a secure way to do authentication but + # but we can use it for local development (i.e. Postman) + # Refer to: https://www.ory.sh/docs/kratos/self-service/flows/user-login#login-for-api-clients-and-clients-without-browsers + - handler: bearer_token + authorizer: + handler: allow + mutators: + - handler: id_token + errors: + - handler: redirect + config: + to: http://127.0.0.1:8080/login diff --git a/auth_volumes/oathkeeper/id_token.jwks.json b/auth_volumes/oathkeeper/id_token.jwks.json new file mode 100644 index 00000000..5bc1ec15 --- /dev/null +++ b/auth_volumes/oathkeeper/id_token.jwks.json @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "use": "sig", + "kty": "RSA", + "kid": "a2aa9739-d753-4a0d-87ee-61f101050277", + "alg": "RS256", + "n": "zpjSl0ySsdk_YC4ZJYYV-cSznWkzndTo0lyvkYmeBkW60YHuHzXaviHqonY_DjFBdnZC0Vs_QTWmBlZvPzTp4Oni-eOetP-Ce3-B8jkGWpKFOjTLw7uwR3b3jm_mFNiz1dV_utWiweqx62Se0SyYaAXrgStU8-3P2Us7_kz5NnBVL1E7aEP40aB7nytLvPhXau-YhFmUfgykAcov0QrnNY0DH0eTcwL19UysvlKx6Uiu6mnbaFE1qx8X2m2xuLpErfiqj6wLCdCYMWdRTHiVsQMtTzSwuPuXfH7J06GTo3I1cEWN8Mb-RJxlosJA_q7hEd43yYisCO-8szX0lgCasw", + "e": "AQAB", + "d": "x3dfY_rna1UQTmFToBoMn6Edte47irhkra4VSNPwwaeTTvI-oN2TO51td7vo91_xD1nw-0c5FFGi4V2UfRcudBv9LD1rHt_O8EPUh7QtAUeT3_XXgjx1Xxpqu5goMZpkTyGZ-B6JzOY3L8lvWQ_Qeia1EXpvxC-oTOjJnKZeuwIPlcoNKMRU-mIYOnkRFfnUvrDm7N9UZEp3PfI3vhE9AquP1PEvz5KTUYkubsfmupqqR6FmMUm6ulGT7guhBw9A3vxIYbYGKvXLdBvn68mENrEYxXrwmu6ITMh_y208M5rC-hgEHIAIvMu1aVW6jNgyQTunsGST3UyrSbwjI0K9UQ", + "p": "77fDvnfHRFEgyi7mh0c6fAdtMEMJ05W8NwTG_D-cSwfWipfTwJJrroWoRwEgdAg5AWGq-MNUzrubTVXoJdC2T4g1o-VRZkKKYoMvav3CvOIMzCBxBs9I_GAKr5NCSk7maksMqiCTMhmkoZ5RPuMYMY_YzxKNAbjBd9qFLfaVAqs", + "q": "3KEmPA2XQkf7dvtpY1Xkp1IfMV_UBdmYk7J6dB5BYqzviQWdEFvWaSATJ_7qV1dw0JDZynOgipp8gvoL-RepfjtArhPz41wB3J2xmBYrBr1sJ-x5eqAvMkQk2bd5KTor44e79TRIkmkFYAIdUQ5JdVXPA13S8WUZfb_bAbwaCBk", + "dp": "5uyy32AJkNFKchqeLsE6INMSp0RdSftbtfCfM86fZFQno5lA_qjOnO_avJPkTILDT4ZjqoKYxxJJOEXCffNCPPltGvbE5GrDXsUbP8k2-LgWNeoml7XFjIGEqcCFQoohQ1IK4DTDN6cmRh76C0e_Pbdh15D6TydJEIlsdGuu_kM", + "dq": "aegFNYCEojFxeTzX6vIZL2RRSt8oJKK-Be__reu0EUzYMtr5-RdMhev6phFMph54LfXKRc9ZOg9MQ4cJ5klAeDKzKpyzTukkj6U20b2aa8LTvxpZec6YuTVSxxu2Ul71IGRQijTNvVIiXWLGddk409Ub6Q7JqkyQfvdwhpWnnUk", + "qi": "P68-EwgcRy9ce_PZ75c909cU7dzCiaGcTX1psJiXmQAFBcG0msWfsyHGbllOZG27pKde78ORGJDYDNk1FqTwsogZyCP87EiBmOoqXWnMvKYfJ1DOx7x42LMAGwMD3bgQj9jgRACxFJG4n3NI6uFlFruyl_CLQzwW_rQFHshLK7Q" + } + ] +} diff --git a/auth_volumes/oathkeeper/oathkeeper.yml b/auth_volumes/oathkeeper/oathkeeper.yml new file mode 100644 index 00000000..26137b63 --- /dev/null +++ b/auth_volumes/oathkeeper/oathkeeper.yml @@ -0,0 +1,100 @@ +log: + level: info + format: json + +serve: + proxy: + cors: + enabled: true + allowed_origins: + - "*" + allowed_methods: + - POST + - GET + - PUT + - PATCH + - DELETE + allowed_headers: + - Authorization + - Content-Type + - Accept + exposed_headers: + - Content-Type + allow_credentials: true + debug: true + +errors: + fallback: + - json + + handlers: + redirect: + enabled: true + config: + to: http://127.0.0.1:8080/login + when: + - + error: + - unauthorized + - forbidden + request: + header: + accept: + - text/html + json: + enabled: true + config: + verbose: true + +access_rules: + matching_strategy: glob + repositories: + - file:///etc/config/oathkeeper/access-rules.yml + +authenticators: + anonymous: + enabled: true + config: + subject: guest + + cookie_session: + enabled: true + config: + check_session_url: http://kratos:4433/sessions/whoami + preserve_path: true + extra_from: "@this" + subject_from: "identity.id" + only: + - ory_kratos_session + + # Note this is not a secure way to do authentication but + # but we can use it for local development (i.e. Postman) + # Refer to: https://www.ory.sh/docs/kratos/self-service/flows/user-login#login-for-api-clients-and-clients-without-browsers + bearer_token: + enabled: true + config: + check_session_url: http://kratos:4433/sessions/whoami + preserve_path: true + extra_from: "@this" + subject_from: "identity.id" + + noop: + enabled: true + +authorizers: + allow: + enabled: true + +mutators: + noop: + enabled: true + + id_token: + enabled: true + config: + issuer_url: http://127.0.0.1:8080/ + jwks_url: file:///etc/config/oathkeeper/id_token.jwks.json + claims: | + { + "session": {{ .Extra | toJson }} + } diff --git a/docker-compose.yaml b/docker-compose.yaml index 13669107..f598c1bb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,37 +1,4 @@ services: - otel-collector: - image: otel/opentelemetry-collector-contrib - volumes: - - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml - - redis: - image: redis - - dramatiq-worker: - build: - dockerfile: Dockerfile - context: . - target: dramatiq_app - env_file: local.env - environment: - OTEL_SERVICE_NAME: "bootstrap-fastapi-dramatiq-worker" - working_dir: "/app/src" - volumes: - - '.:/app' - depends_on: - - redis - - otel-collector - command: - - opentelemetry-instrument - - dramatiq - - --watch - - . - - -p - - "1" - - -t - - "1" - - dramatiq_worker - dev: build: dockerfile: Dockerfile @@ -46,7 +13,7 @@ services: volumes: - '.:/app' depends_on: - - redis + - dramatiq-worker - otel-collector command: - opentelemetry-instrument @@ -60,13 +27,14 @@ services: # Remember to disable the reloader in order to allow otel instrumentation - --reload + # Production image http: build: dockerfile: Dockerfile context: . target: http_app depends_on: - - redis + - dramatiq-worker - otel-collector env_file: local.env environment: @@ -76,7 +44,114 @@ services: volumes: - './src/sqlite.db:/app/sqlite.db' - # Starting from here there are only single-run commands, we can use `make` here + ######################### + #### Helper services #### + ######################### + + otel-collector: + image: otel/opentelemetry-collector-contrib + volumes: + - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml + + redis: + image: redis + + dramatiq-worker: + build: + dockerfile: Dockerfile + context: . + target: dramatiq_app + env_file: local.env + environment: + OTEL_SERVICE_NAME: "bootstrap-fastapi-dramatiq-worker" + working_dir: "/app/src" + volumes: + - '.:/app' + depends_on: + - redis + - otel-collector + command: + - opentelemetry-instrument + - dramatiq + - --watch + - . + - -p + - "1" + - -t + - "1" + - dramatiq_worker + + ################################# + #### Authentication services #### + ################################# + + kratos-migrate: + image: oryd/kratos:v1.3.1 + environment: + DSN: "sqlite:///etc/config/kratos/db.sqlite?_fk=true&mode=rwc" + volumes: + - ./auth_volumes/kratos:/etc/config/kratos + command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes + restart: on-failure + + kratos: + depends_on: + - kratos-migrate + image: oryd/kratos:v1.3.1 + # It's not needed to expose these, leaving for documentation +# ports: +# - '4433:4433' # public API +# - '4434:4434' # admin API + restart: unless-stopped + environment: + DSN: "sqlite:///etc/config/kratos/db.sqlite?_fk=true&mode=rwc" + LOG_LEVEL: "trace" + volumes: + - ./auth_volumes/kratos:/etc/config/kratos + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + + auth-ui: + image: oryd/kratos-selfservice-ui-node:v1.3.1 + environment: + PORT: 3000 + # Internal access URL for the BFF instance + KRATOS_PUBLIC_URL: "http://kratos:4433/" + # External access URL for the browser + KRATOS_BROWSER_URL: "http://127.0.0.1:8080/.ory/kratos/public" + JWKS_URL: "http://oathkeeper:4456/.well-known/jwks.json" + SECURITY_MODE: "jwks" + COOKIE_SECRET: "changeme" + CSRF_COOKIE_NAME: "ory_csrf_ui" + CSRF_COOKIE_SECRET: "changeme" + restart: on-failure + +# mailslurper: +# image: oryd/mailslurper:latest-smtps +# ports: +# - '4436:4436' +# - '4437:4437' + + oathkeeper: + image: oryd/oathkeeper:v0.40.8 + depends_on: + - kratos + - auth-ui + - dev + ports: + # Public traffic port + - "8080:4455" + # Private traffic port, this is not usually exposed + # among other things it provides the JWKS url + # - "4456:4456" + command: + serve proxy -c "/etc/config/oathkeeper/oathkeeper.yml" + restart: on-failure + volumes: + - ./auth_volumes/oathkeeper:/etc/config/oathkeeper + + ########################## + #### One-off commands #### + ########################## test: build: dockerfile: Dockerfile diff --git a/docs/.pages b/docs/.pages index 3a1e48d7..6e32a50a 100644 --- a/docs/.pages +++ b/docs/.pages @@ -2,8 +2,9 @@ nav: - Home: index.md - api-documentation.md - architecture.md - - src packages: packages + - zero_trust.md - inversion-of-control.md - dockerfile.md + - src packages: packages - ... - ADR: adr diff --git a/docs/zero_trust.md b/docs/zero_trust.md new file mode 100644 index 00000000..f7decaa9 --- /dev/null +++ b/docs/zero_trust.md @@ -0,0 +1,59 @@ +# Zero Trust architecture + +This repository implements [ORY Zero Trust architecture](https://www.ory.sh/docs/kratos/guides/zero-trust-iap-proxy-identity-access-proxy) +using: + +* [ORY Kratos Identity Server](https://github.com/ory/kratos) as authentication and identity provider. +* [ORY Oathkeeper](https://github.com/ory/oathkeeper) as reverse proxy to take care of authentication and access control. + +If you access the API docs at `/docs` you will notice that the `/hello/` endpoint +is protected but the authentication infrastructure doesn't spin up when running +`docker compuse up dev`. + +You can spin up all the authentication infrastructure by running `docker compose up oathkeeper`. +You should be able to access the authentication UI at [http://127.0.0.1:8080](http://127.0.0.1:8080) and, +after you will be authenticated, you will be able to access the protected `/hello` +endpoint at [http://127.0.0.1:8080/hello](http://127.0.0.1:8080/hello) + +/// admonition | Cookie-based security + type: warning + +The current setup is built around the example authentication UI provided by ORY, +which uses the flows for browser-based application, with CSRF protection, and stores +the session token using Cookies. + +While this is not a bad approach, it is not suitable for Single Page Applications +and API-based clients, because it is open to different vector attacks (CSRF among them). + +Reference: [https://www.ory.sh/docs/kratos/self-service/flows/user-login#login-for-api-clients-and-clients-without-browsers](https://www.ory.sh/docs/kratos/self-service/flows/user-login#login-for-api-clients-and-clients-without-browsers) + +🚧 An authentication flow using [Oauth2](https://oauth.net/2/), based on [ORY Hydra](https://github.com/ory/hydra) +and integrated with this setup, will be added in the future. It will provide provide secure flows for SPAs and other +API based clients. 🚧 +/// + +This is a high level representation of the used components: + +```mermaid +graph TD +subgraph hn[Host Network] + B[Browser] + B-->|Can access URLs via 127.0.0.1:8080|OKPHN + B-->|Can access URLs via 127.0.0.1:8000|DEVHN + OKPHN([Reverse Proxy exposed at :8080]) + DEVHN([Dev Container exposed at :8000]) +end +subgraph dn["Internal Docker Network (intranet)"] + OKPHN-->OO + DEVHN-->DEV + OO-->|Proxies URLss /.ory/kratos/public/* to|OK + OO-->|"Proxies /auth/login, /auth/registration, /dashboard, ... to"|SA + SA-->|Talks to|OK + OO-->|Validates auth sessions using|OK + OO-->|"Proxies /hello to"|DEV + OK[Ory Kratos] + OO["Reverse Proxy (Ory Oathkeeper)"] + SA["SecureApp (Ory Kratos SelfService UI Node Example)"] + DEV[Dev Container] +end +``` \ No newline at end of file diff --git a/local.env b/local.env index 28062273..c33c1ba3 100644 --- a/local.env +++ b/local.env @@ -1,2 +1,3 @@ +AUTH__JWKS_URL: "http://oathkeeper:4456/.well-known/jwks.json" DRAMATIQ__REDIS_URL: "redis://redis:6379/0" OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4317" diff --git a/pyproject.toml b/pyproject.toml index 4bb316dd..84dc6938 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,11 +32,13 @@ dependencies = [ [dependency-groups] http = [ + "cryptography>=44.0.0", "fastapi>=0.99.0", "jinja2<4.0.0,>=3.1.2", # We use the generic ASGI instrumentation, so that if we decide to change # Framework it will still work consistently. "opentelemetry-instrumentation-asgi", + "pyjwt>=2.10.1", "starlette-prometheus<1.0.0,>=0.10.0", "strawberry-graphql[debug-server]>=0.204.0", "uvicorn[standard]<1.0.0,>=0.34.0", diff --git a/src/common/config.py b/src/common/config.py index cdc344e2..aa195e31 100644 --- a/src/common/config.py +++ b/src/common/config.py @@ -12,10 +12,16 @@ class DramatiqConfig(BaseModel): REDIS_URL: Optional[str] = None +class AuthConfig(BaseModel): + JWT_ALGORITHM: str = "RS256" + JWKS_URL: Optional[str] = None + + class AppConfig(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter="__") APP_NAME: str = "bootstrap" + AUTH: AuthConfig = AuthConfig() DRAMATIQ: DramatiqConfig = DramatiqConfig() DEBUG: bool = False ENVIRONMENT: TYPE_ENVIRONMENT = "local" diff --git a/src/http_app/__init__.py b/src/http_app/__init__.py index 21a1902e..74166c57 100644 --- a/src/http_app/__init__.py +++ b/src/http_app/__init__.py @@ -7,6 +7,7 @@ from structlog import get_logger from common import AppConfig, application_init +from http_app import context from http_app.routes import init_routes @@ -14,6 +15,16 @@ def create_app( test_config: Union[AppConfig, None] = None, ) -> FastAPI: app_config = test_config or AppConfig() + + """ + The config is submitted here at runtime, this means + that we cannot declare a function to be used with + FastAPI dependency injection system because Depends + is evaluated before this function is called. + A context variable will achieve the same purpose. + """ + context.app_config.set(app_config) + application_init(app_config) app = FastAPI( debug=app_config.DEBUG, diff --git a/src/http_app/context.py b/src/http_app/context.py new file mode 100644 index 00000000..6369d548 --- /dev/null +++ b/src/http_app/context.py @@ -0,0 +1,5 @@ +from contextvars import ContextVar + +from common import AppConfig + +app_config: ContextVar[AppConfig] = ContextVar("app_config") diff --git a/src/http_app/dependencies.py b/src/http_app/dependencies.py new file mode 100644 index 00000000..d576c5c3 --- /dev/null +++ b/src/http_app/dependencies.py @@ -0,0 +1,6 @@ +from common import AppConfig +from http_app import context + + +def app_config() -> AppConfig: + return context.app_config.get() diff --git a/src/http_app/jinja_templates/hello.html b/src/http_app/jinja_templates/hello.html index 0d7dc983..b9c9e0db 100644 --- a/src/http_app/jinja_templates/hello.html +++ b/src/http_app/jinja_templates/hello.html @@ -4,5 +4,7 @@

Hello world

+

Your JWT token payload:

+
{{ token_payload | tojson(4) }}
\ No newline at end of file diff --git a/src/http_app/routes/auth.py b/src/http_app/routes/auth.py new file mode 100644 index 00000000..e534298f --- /dev/null +++ b/src/http_app/routes/auth.py @@ -0,0 +1,64 @@ +from typing import Annotated, Optional + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes + +from common import AppConfig +from http_app.dependencies import app_config + + +class MissingAuthorizationServerException(HTTPException): + def __init__(self, **kwargs): + super().__init__( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authorization server not available", + ) + + +class UnauthorizedException(HTTPException): + def __init__(self, detail: str, **kwargs): + super().__init__(status.HTTP_403_FORBIDDEN, detail=detail) + + +class UnauthenticatedException(HTTPException): + def __init__(self): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Requires authentication" + ) + + +def _jwks_client(config: Annotated[AppConfig, Depends(app_config)]) -> jwt.PyJWKClient: + if not config.AUTH.JWKS_URL: + raise MissingAuthorizationServerException() + return jwt.PyJWKClient(config.AUTH.JWKS_URL) + + +async def decode_jwt( + security_scopes: SecurityScopes, + config: AppConfig = Depends(app_config), + jwks_client: jwt.PyJWKClient = Depends(_jwks_client), + token: Optional[HTTPAuthorizationCredentials] = Depends(HTTPBearer()), +): + if token is None: + raise UnauthenticatedException() + + try: + signing_key = jwks_client.get_signing_key_from_jwt(token.credentials).key + except jwt.exceptions.PyJWKClientError as error: + raise UnauthorizedException(str(error)) + except jwt.exceptions.DecodeError as error: + raise UnauthorizedException(str(error)) + + try: + # TODO: Review decode setup and verifications + # https://pyjwt.readthedocs.io/en/stable/api.html#jwt.decode + payload = jwt.decode( + jwt=token.credentials, + key=signing_key, + algorithms=[config.AUTH.JWT_ALGORITHM], + ) + except Exception as error: + raise UnauthorizedException(str(error)) + + return payload diff --git a/src/http_app/routes/hello.py b/src/http_app/routes/hello.py index 617062f5..c912751b 100644 --- a/src/http_app/routes/hello.py +++ b/src/http_app/routes/hello.py @@ -1,11 +1,15 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Security from fastapi.responses import HTMLResponse from http_app.templates import templates +from .auth import decode_jwt + router = APIRouter(prefix="/hello") -@router.get("/", response_class=HTMLResponse, include_in_schema=False) -async def hello(request: Request): - return templates.TemplateResponse("hello.html", {"request": request}) +@router.get("/", response_class=HTMLResponse, include_in_schema=True) +async def hello(request: Request, jwt_token=Security(decode_jwt)): + return templates.TemplateResponse( + "hello.html", {"request": request, "token_payload": jwt_token} + ) diff --git a/tests/http_app/routes/test_auth.py b/tests/http_app/routes/test_auth.py new file mode 100644 index 00000000..98ff7a05 --- /dev/null +++ b/tests/http_app/routes/test_auth.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.security import HTTPAuthorizationCredentials, SecurityScopes +from jwt import PyJWK, PyJWKClient +from jwt.exceptions import DecodeError, PyJWKClientError + +from common import AppConfig +from common.config import AuthConfig +from http_app.routes.auth import ( + MissingAuthorizationServerException, + UnauthenticatedException, + UnauthorizedException, + _jwks_client, + decode_jwt, +) + + +def test_jwks_client_raises_without_jwks_url(): + with pytest.raises(MissingAuthorizationServerException): + _jwks_client(config=AppConfig(AUTH=AuthConfig(JWKS_URL=None))) + + +def test_jwks_client_returns_a_client_with_jwks_url(): + result = _jwks_client(config=AppConfig(AUTH=AuthConfig(JWKS_URL="http://test.com"))) + assert isinstance(result, PyJWKClient) + + +async def test_decode_jwt_raises_without_token(): + with pytest.raises(UnauthenticatedException): + await decode_jwt( + security_scopes=SecurityScopes(), + config=AppConfig(), + jwks_client=MagicMock(), + token=None, + ) + + +@pytest.mark.parametrize("exception", (PyJWKClientError, DecodeError)) +async def test_decode_jwt_raises_if_jwks_client_fails(exception): + mock_jwks_client = MagicMock(spec=PyJWKClient) + mock_jwks_client.get_signing_key_from_jwt = MagicMock(side_effect=exception) + with pytest.raises(UnauthorizedException): + await decode_jwt( + security_scopes=SecurityScopes(), + config=AppConfig(), + jwks_client=mock_jwks_client, + token=HTTPAuthorizationCredentials( + scheme="bearer", credentials="some_token" + ), + ) + + +async def test_decode_jwt_raises_if_decode_fails(): + returned_key = MagicMock(spec=PyJWK) + returned_key.key = "some_key" + mock_jwks_client = MagicMock(spec=PyJWKClient) + mock_jwks_client.get_signing_key_from_jwt = MagicMock(return_value=returned_key) + + with pytest.raises(UnauthorizedException): + await decode_jwt( + security_scopes=SecurityScopes(), + config=AppConfig(), + jwks_client=mock_jwks_client, + token=HTTPAuthorizationCredentials( + # The token cannot be decrypted and will trigger the exception + scheme="bearer", + credentials="some_token", + ), + ) + + +async def test_decode_jwt_returns_the_decoded_jwt_payload(): + returned_key = MagicMock(spec=PyJWK) + returned_key.key = "some_key" + mock_jwks_client = MagicMock(spec=PyJWKClient) + mock_jwks_client.get_signing_key_from_jwt = MagicMock(return_value=returned_key) + + with patch("jwt.decode", return_value={"decoded": "token"}): + result = await decode_jwt( + security_scopes=SecurityScopes(), + config=AppConfig(), + jwks_client=mock_jwks_client, + token=HTTPAuthorizationCredentials( + # The token cannot be decrypted and will trigger the exception + scheme="bearer", + credentials="some_token", + ), + ) + + assert result == {"decoded": "token"} diff --git a/tests/http_app/routes/test_hello.py b/tests/http_app/routes/test_hello.py index a9af565e..904f9c5b 100644 --- a/tests/http_app/routes/test_hello.py +++ b/tests/http_app/routes/test_hello.py @@ -1,7 +1,34 @@ +from fastapi import Depends, FastAPI, status +from fastapi.security import HTTPBearer from fastapi.testclient import TestClient +from http_app.routes.auth import decode_jwt -async def test_root(testapp): + +async def _fake_decode_jwt( + security_scopes=None, + config=None, + jwks_client=None, + token=Depends(HTTPBearer()), +): + return {"token": token.credentials} + + +async def test_hello_renders_what_returned_by_decoder( + testapp: FastAPI, +): + testapp.dependency_overrides[decode_jwt] = _fake_decode_jwt + ac = TestClient(app=testapp, base_url="http://test") + response = ac.get( + "/hello/", + headers={"Authorization": "Bearer some_token"}, + ) + assert response.status_code == status.HTTP_200_OK + assert '"token": "some_token"' in response.text + + +async def test_hello_returns_403_without_token(testapp: FastAPI): + testapp.dependency_overrides[decode_jwt] = _fake_decode_jwt ac = TestClient(app=testapp, base_url="http://test") response = ac.get("/hello/") - assert response.status_code == 200 + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/http_app/test_dependencies.py b/tests/http_app/test_dependencies.py new file mode 100644 index 00000000..2a52322d --- /dev/null +++ b/tests/http_app/test_dependencies.py @@ -0,0 +1,9 @@ +from common import AppConfig +from http_app import context +from http_app.dependencies import app_config + + +def test_app_config_return_context_variable(): + config = AppConfig(APP_NAME="SomeOtherAppName") + context.app_config.set(config) + assert app_config() is config diff --git a/uv.lock b/uv.lock index 2ee355a8..e47c9763 100644 --- a/uv.lock +++ b/uv.lock @@ -149,9 +149,11 @@ dev = [ { name = "strawberry-graphql", extra = ["debug-server", "fastapi"] }, ] http = [ + { name = "cryptography" }, { name = "fastapi" }, { name = "jinja2" }, { name = "opentelemetry-instrumentation-asgi" }, + { name = "pyjwt" }, { name = "starlette-prometheus" }, { name = "strawberry-graphql", extra = ["debug-server"] }, { name = "uvicorn", extra = ["standard"] }, @@ -201,9 +203,11 @@ dev = [ { name = "strawberry-graphql", extras = ["debug-server", "fastapi"] }, ] http = [ + { name = "cryptography", specifier = ">=44.0.0" }, { name = "fastapi", specifier = ">=0.99.0" }, { name = "jinja2", specifier = ">=3.1.2,<4.0.0" }, { name = "opentelemetry-instrumentation-asgi" }, + { name = "pyjwt", specifier = ">=2.10.1" }, { name = "starlette-prometheus", specifier = ">=0.10.0,<1.0.0" }, { name = "strawberry-graphql", extras = ["debug-server"], specifier = ">=0.204.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0,<1.0.0" }, @@ -236,14 +240,62 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, ] @@ -443,6 +495,43 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "44.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 }, + { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 }, + { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 }, + { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, + { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, + { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, + { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, + { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, + { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, + { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 }, + { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 }, + { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 }, + { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 }, + { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, + { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, + { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, + { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, + { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, + { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, + { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 }, + { url = "https://files.pythonhosted.org/packages/77/d4/fea74422326388bbac0c37b7489a0fcb1681a698c3b875959430ba550daa/cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731", size = 3338857 }, + { url = "https://files.pythonhosted.org/packages/1a/aa/ba8a7467c206cb7b62f09b4168da541b5109838627f582843bbbe0235e8e/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4", size = 3850615 }, + { url = "https://files.pythonhosted.org/packages/89/fa/b160e10a64cc395d090105be14f399b94e617c879efd401188ce0fea39ee/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756", size = 4081622 }, + { url = "https://files.pythonhosted.org/packages/47/8f/20ff0656bb0cf7af26ec1d01f780c5cfbaa7666736063378c5f48558b515/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c", size = 3867546 }, + { url = "https://files.pythonhosted.org/packages/38/d9/28edf32ee2fcdca587146bcde90102a7319b2f2c690edfa627e46d586050/cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa", size = 4090937 }, + { url = "https://files.pythonhosted.org/packages/cc/9d/37e5da7519de7b0b070a3fedd4230fe76d50d2a21403e0f2153d70ac4163/cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c", size = 3128774 }, +] + [[package]] name = "dependency-injector" version = "4.44.0" @@ -1960,6 +2049,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + [[package]] name = "pymdown-extensions" version = "10.13"