From f61c4f18e5e3472092d6947f8a923003f12b44b8 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Tue, 3 Mar 2026 16:26:17 +0100 Subject: [PATCH 01/24] Introduce BFF service with Flask app, OAuth, and OpenAPI support - Create Flask application with comprehensive OAuth endpoints (login, callback, logout, session check). - Add route modules for proxying, health checks, and authentication workflows. - Define service layer helpers for managing authentication and token refresh. - Provide OpenAPI generation script and CI tasks to validate specification consistency. - Integrate Docker setup for local and containerized execution. - Include pytest configuration and fixtures for testing. - Add GitHub Actions workflow for CI tasks (test, OpenAPI spec drift check). --- .github/dependabot.yml | 8 + .github/workflows/bff.yml | 39 +++ bff/.gitignore | 6 + bff/Dockerfile | 29 ++ bff/README.md | 70 ++++ bff/bff.py | 18 + bff/bff_app/__init__.py | 60 ++++ bff/bff_app/openapi/__init__.py | 2 + bff/bff_app/openapi/generate.py | 98 ++++++ bff/bff_app/openapi/openapi.yaml | 348 +++++++++++++++++++ bff/bff_app/routes/__init__.py | 1 + bff/bff_app/routes/auth.py | 303 +++++++++++++++++ bff/bff_app/routes/health.py | 41 +++ bff/bff_app/routes/proxy.py | 116 +++++++ bff/bff_app/services/__init__.py | 1 + bff/bff_app/services/auth.py | 56 ++++ bff/bff_app/settings.py | 88 +++++ bff/docker-compose.yml | 14 + bff/pyproject.toml | 21 ++ bff/tests/conftest.py | 50 +++ bff/tests/test_auth_callback.py | 46 +++ bff/tests/test_auth_login.py | 29 ++ bff/tests/test_auth_logout.py | 23 ++ bff/tests/test_auth_session.py | 68 ++++ bff/tests/test_auth_userinfo.py | 19 ++ bff/tests/test_openapi_generation.py | 47 +++ bff/tests/test_ping.py | 5 + bff/tests/test_proxy_request.py | 79 +++++ bff/uv.lock | 484 +++++++++++++++++++++++++++ 29 files changed, 2169 insertions(+) create mode 100644 .github/workflows/bff.yml create mode 100644 bff/.gitignore create mode 100644 bff/Dockerfile create mode 100644 bff/README.md create mode 100644 bff/bff.py create mode 100644 bff/bff_app/__init__.py create mode 100644 bff/bff_app/openapi/__init__.py create mode 100644 bff/bff_app/openapi/generate.py create mode 100644 bff/bff_app/openapi/openapi.yaml create mode 100644 bff/bff_app/routes/__init__.py create mode 100644 bff/bff_app/routes/auth.py create mode 100644 bff/bff_app/routes/health.py create mode 100644 bff/bff_app/routes/proxy.py create mode 100644 bff/bff_app/services/__init__.py create mode 100644 bff/bff_app/services/auth.py create mode 100644 bff/bff_app/settings.py create mode 100644 bff/docker-compose.yml create mode 100644 bff/pyproject.toml create mode 100644 bff/tests/conftest.py create mode 100644 bff/tests/test_auth_callback.py create mode 100644 bff/tests/test_auth_login.py create mode 100644 bff/tests/test_auth_logout.py create mode 100644 bff/tests/test_auth_session.py create mode 100644 bff/tests/test_auth_userinfo.py create mode 100644 bff/tests/test_openapi_generation.py create mode 100644 bff/tests/test_ping.py create mode 100644 bff/tests/test_proxy_request.py create mode 100644 bff/uv.lock diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d3ee04d1..f7e5c28e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,6 +16,14 @@ updates: patch: update-types: - "patch" + - package-ecosystem: "uv" + directory: "/bff" + schedule: + interval: "daily" + groups: + patch: + update-types: + - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/bff.yml b/.github/workflows/bff.yml new file mode 100644 index 00000000..ff86b59a --- /dev/null +++ b/.github/workflows/bff.yml @@ -0,0 +1,39 @@ +name: BFF CI + +on: + pull_request: + paths: + - 'bff/**' + push: + branches: + - main + paths: + - 'bff/**' + workflow_dispatch: + +defaults: + run: + working-directory: bff + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python and install deps + uses: ./.github/actions/setup-python + with: + python-version: '3.12' + project-dir: 'bff' + cache-dependency-path: | + bff/pyproject.toml + bff/uv.lock + install-dev: 'true' + + - name: Run tests + run: uv run pytest -q + + - name: Check OpenAPI spec drift + run: uv run python -m bff_app.openapi.generate --check --output bff_app/openapi/openapi.yaml diff --git a/bff/.gitignore b/bff/.gitignore new file mode 100644 index 00000000..7d1d96fc --- /dev/null +++ b/bff/.gitignore @@ -0,0 +1,6 @@ +/.env +.env.* +.idea +**/*/__pycache__ +__pycache__/ +.venv diff --git a/bff/Dockerfile b/bff/Dockerfile new file mode 100644 index 00000000..3831d924 --- /dev/null +++ b/bff/Dockerfile @@ -0,0 +1,29 @@ +# Use an official Python 3.12 image as a parent image +FROM python:3.12 + +# Set environment variables +ENV FLASK_APP=bff.py +ENV FLASK_RUN_HOST=0.0.0.0 + +# Set the working directory in the container +WORKDIR /app + +# Install uv +RUN pip install --no-cache-dir uv + +# Copy dependency metadata first for cache-friendly installs +COPY pyproject.toml uv.lock ./ + +# Install dependencies from uv.lock (without dev dependencies) +RUN uv sync --frozen --no-dev --no-install-project + +# Copy the current directory contents into the container at /app +# Do this after installing the dependencies to avoid breaking Docker cache +COPY . /app + +# Make port 5000 available to the world outside this container +ARG PORT +EXPOSE $PORT + +# Run app.py when the container launches +CMD ["sh", "-c", "uv run flask --app bff.py run --host 0.0.0.0 --port ${PORT:-5000}"] diff --git a/bff/README.md b/bff/README.md new file mode 100644 index 00000000..fa81d17b --- /dev/null +++ b/bff/README.md @@ -0,0 +1,70 @@ +# MMS Backend-for-Frontend (BFF) + +This repo provides a Flask BFF that handles OAuth login/logout/session checks and proxies REST calls to a backend. + +**Quick Start** +1. Create `.env` from `.env.example` in the repo root (do not commit `.env`). +2. `uv sync --dev` +3. `FLASK_RUN_PORT=5022 uv run flask --app bff.py run` + +**Prerequisites** +- Python 3.x +- `uv` + +**Environment File** +- Location: repo root `.env` (same directory as `bff.py`) +- Template: `.env.example` +- Required values from your OAuth server: +- `OAUTH_CLIENT_ID` +- `OAUTH_CLIENT_SECRET` +- `OAUTH_ENDPOINT_AUTHORIZATION` +- `OAUTH_ENDPOINT_TOKEN` +- `OAUTH_ENDPOINT_USERINFO` +- Required values from your frontend: +- `FRONTEND_REDIRECT` +- `OAUTH_LOGIN_REDIRECT_URI` +- Session cookie configuration: +- `SESSION_COOKIE_NAME` +- `SESSION_COOKIE_SECURE` +- `SESSION_COOKIE_SAMESITE` + +**Run Locally (Flask / PyCharm)** +1. Ensure `.env` exists in the repo root. +2. Install dependencies. +```bash +uv sync --dev +``` +3. Set the Flask port and run. +```bash +FLASK_RUN_PORT=5022 uv run flask --app bff.py run +``` +- Why: `flask run` reads `FLASK_RUN_PORT` (not `PORT`). +- In PyCharm, set the working directory to the repo root so `.env` is picked up. +- If you prefer, you can pass `--port 5022` in the run configuration. + +**Generate OpenAPI Spec** +1. Generate or refresh the spec file. +```bash +uv run python -m bff_app.openapi.generate --output openapi.yaml +``` +2. Validate that the committed spec is up to date. +```bash +uv run python -m bff_app.openapi.generate --check --output openapi.yaml +``` + +**Run with Docker** +1. Ensure `.env` exists in the repo root. +2. Build and run. +```bash +docker-compose up --build +``` +- Docker uses `PORT` from `.env` for the exposed port. + +**Security Notes** +- Tokens, PKCE verifier, and OAuth state are stored in Flask's signed session cookie. +- This service does not currently configure Flask-Session/Redis-backed server-side session storage. +- Keep `SESSION_COOKIE_SECURE=True` and `SESSION_COOKIE_SAMESITE=Strict` in production. + +**Notes / Common Pitfalls** +- If you run `bff.py` directly (not `flask run`), Flask will default to port 5000 unless you explicitly set the port in code. +- `dotenv` loads the `.env` file at process start. If the `.env` is missing or the working directory is wrong, defaults will be used. diff --git a/bff/bff.py b/bff/bff.py new file mode 100644 index 00000000..54b88b36 --- /dev/null +++ b/bff/bff.py @@ -0,0 +1,18 @@ +"""Application entrypoint for the Backend-for-Frontend service. + +This module loads environment variables and builds the Flask app via +the application factory. +""" + +import dotenv + +from bff_app import create_app +from bff_app.settings import load_settings_from_env + +dotenv.load_dotenv() + +app = create_app(settings=load_settings_from_env()) + + +if __name__ == "__main__": + app.run() diff --git a/bff/bff_app/__init__.py b/bff/bff_app/__init__.py new file mode 100644 index 00000000..95ffe4ce --- /dev/null +++ b/bff/bff_app/__init__.py @@ -0,0 +1,60 @@ +"""Flask application factory and top-level wiring for the BFF service.""" + +from __future__ import annotations + +from flask import Flask +from flask_cors import CORS +from flask_smorest import Api + +from .routes.auth import auth_bp +from .routes.health import health_bp +from .routes.proxy import proxy_bp +from .settings import BffSettings + + +def create_app(settings: BffSettings) -> Flask: + """Create and configure the Flask application. + + :param settings: + Fully resolved runtime settings loaded from environment variables. + :returns: + Configured Flask application with registered blueprints and CORS. + :rtype: flask.Flask + """ + app = Flask(__name__) + + app.config["SECRET_KEY"] = settings.flask_secret_key + app.config["SESSION_COOKIE_NAME"] = settings.session_cookie_name + app.config["SESSION_COOKIE_PATH"] = settings.session_cookie_path + app.config["SESSION_COOKIE_HTTPONLY"] = settings.session_cookie_httponly + app.config["SESSION_COOKIE_SECURE"] = settings.session_cookie_secure + app.config["SESSION_COOKIE_SAMESITE"] = settings.session_cookie_samesite + app.config["API_TITLE"] = "BFF Flask API" + app.config["API_VERSION"] = "1.0.0" + app.config["OPENAPI_VERSION"] = "3.0.3" + + app.extensions["bff_settings"] = settings + + cors_kwargs = {"supports_credentials": True} + if settings.cors_allowed_origin: + cors_kwargs["origins"] = settings.cors_allowed_origin + CORS(app, **cors_kwargs) + + api = Api(app) + api.spec.components.security_scheme( + "sessionCookie", + { + "type": "apiKey", + "in": "cookie", + "name": "SESSION_COOKIE_NAME", + "description": ( + "Signed session cookie name is configured at runtime via " + "SESSION_COOKIE_NAME in the app environment." + ), + }, + ) + api.register_blueprint(auth_bp) + api.register_blueprint(proxy_bp) + api.register_blueprint(health_bp) + + return app diff --git a/bff/bff_app/openapi/__init__.py b/bff/bff_app/openapi/__init__.py new file mode 100644 index 00000000..f79433d8 --- /dev/null +++ b/bff/bff_app/openapi/__init__.py @@ -0,0 +1,2 @@ +"""OpenAPI utilities for the BFF app.""" + diff --git a/bff/bff_app/openapi/generate.py b/bff/bff_app/openapi/generate.py new file mode 100644 index 00000000..e38877bb --- /dev/null +++ b/bff/bff_app/openapi/generate.py @@ -0,0 +1,98 @@ +"""Generate OpenAPI spec from flask-smorest decorators.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import yaml + +from bff_app import create_app +from bff_app.settings import BffSettings + + +def _spec_settings() -> BffSettings: + """Build deterministic settings for spec export.""" + return BffSettings( + flask_secret_key="openapi-generator", + session_cookie_name="SESSION_COOKIE_NAME", + session_cookie_path="/", + session_cookie_httponly=True, + session_cookie_secure=True, + session_cookie_samesite="Strict", + cors_allowed_origin=None, + backend_endpoint="http://backend.example/api", + oauth_client_id="client-id", + oauth_client_secret="client-secret", + oauth_oidc_scope="openid", + oauth_endpoint_authorization="http://auth.example/authorize", + oauth_endpoint_token="http://auth.example/token", + oauth_endpoint_userinfo="http://auth.example/userinfo", + oauth_endpoint_logout="http://auth.example/logout", + oauth_login_redirect_uri="http://localhost:5022/proxy/api/auth/callback", + frontend_redirect="http://localhost:5178", + ) + + +def build_openapi_document() -> dict: + """Export OpenAPI document from route decorators.""" + app = create_app(settings=_spec_settings()) + api = app.extensions["flask-smorest"]["apis"][""]["ext_obj"] + document = api.spec.to_dict() + document.setdefault("servers", [{"url": "/"}]) + return document + + +def dump_openapi_yaml(document: dict) -> str: + """Serialize OpenAPI document to deterministic YAML.""" + return yaml.safe_dump( + document, + sort_keys=False, + default_flow_style=False, + allow_unicode=False, + ) + + +def generate_openapi(output: Path, check: bool) -> int: + """Generate OpenAPI YAML and write or compare against output path.""" + document = build_openapi_document() + rendered = dump_openapi_yaml(document) + + if check: + if not output.exists(): + print(f"OpenAPI file not found: {output}") + return 1 + current = output.read_text(encoding="utf-8") + if current != rendered: + print( + "OpenAPI spec is out of date. Run:\n" + "uv run python -m bff_app.openapi.generate --output openapi.yaml" + ) + return 1 + print("OpenAPI spec is up to date.") + return 0 + + output.write_text(rendered, encoding="utf-8") + print(f"Wrote OpenAPI spec to {output}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """CLI entrypoint for OpenAPI generation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + default="openapi.yaml", + help="Path of the generated OpenAPI YAML file.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Check if output file is up to date instead of rewriting it.", + ) + args = parser.parse_args(argv) + return generate_openapi(output=Path(args.output), check=args.check) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bff/bff_app/openapi/openapi.yaml b/bff/bff_app/openapi/openapi.yaml new file mode 100644 index 00000000..fbaa42cc --- /dev/null +++ b/bff/bff_app/openapi/openapi.yaml @@ -0,0 +1,348 @@ +paths: + /proxy/api/auth/login: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Auth server redirect URL. + content: + application/json: + schema: + type: object + properties: + redirect: + type: string + required: + - redirect + summary: Start login flow + description: Creates a PKCE authorization URL and stores verifier/state in the + session cookie. + tags: + - auth + /proxy/api/auth/callback: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '302': + description: Redirects to frontend after successful or failed state check. + summary: OAuth callback + description: Exchanges the authorization code for tokens and stores them in + the session. + tags: + - auth + /proxy/api/auth/logout: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Logout successful. + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + summary: Logout and revoke tokens + description: Log out from auth server and clear local session data. + tags: + - auth + security: + - sessionCookie: [] + /proxy/api/auth/userinfo: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: User info from the auth server. + content: + application/json: + schema: + type: object + properties: + email: + type: string + email_verified: + type: boolean + family_name: + type: string + given_name: + type: string + name: + type: string + preferred_username: + type: string + sub: + type: string + required: + - email + - email_verified + - family_name + - given_name + - name + - preferred_username + - sub + examples: + default: + value: + email: olivia.diaz@embergenpower.demo + email_verified: true + family_name: Diaz + given_name: Olivia + name: Olivia Diaz + preferred_username: olivia.diaz@embergenpower.demo + sub: 8b8135af-4555-455c-bf84-567f99ff9e96 + summary: Fetch user info + description: Proxy userinfo endpoint for the authenticated session. + tags: + - auth + security: + - sessionCookie: [] + /proxy/api/auth/session: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Session state. + content: + application/json: + schema: + type: object + properties: + session: + type: boolean + required: + - session + summary: Check session validity + description: Validate whether the current browser session is still authenticated. + tags: + - auth + security: + - sessionCookie: [] + /proxy/api/request/{rest_of_url}: + options: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '204': + description: Preflight response. + summary: REST preflight + tags: + - proxy + parameters: + - in: path + name: rest_of_url + required: true + schema: + type: string + head: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + post: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + put: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + patch: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + delete: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Proxied response from REST backend. + content: + '*/*': + schema: + type: string + format: binary + '502': + description: Upstream connection error. + summary: Proxy REST request + description: Forward the incoming request to BACKEND_ENDPOINT with auth token + passthrough. + tags: + - proxy + security: + - sessionCookie: [] + /ping: + get: + responses: + default: + $ref: '#/components/responses/DEFAULT_ERROR' + '200': + description: Pong response. + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + summary: Health check + description: Return a basic healthcheck response. + tags: + - health +info: + title: BFF Flask API + version: 1.0.0 +tags: +- name: auth + description: Authentication endpoints for login, callback, logout and session checks. +- name: proxy + description: Backend passthrough proxy endpoints. +- name: health + description: Healthcheck endpoints. +openapi: 3.0.3 +components: + schemas: + Error: + type: object + properties: + code: + type: integer + description: Error code + status: + type: string + description: Error name + message: + type: string + description: Error message + errors: + type: object + description: Errors + additionalProperties: {} + additionalProperties: false + PaginationMetadata: + type: object + properties: + total: + type: integer + total_pages: + type: integer + first_page: + type: integer + last_page: + type: integer + page: + type: integer + previous_page: + type: integer + next_page: + type: integer + additionalProperties: false + responses: + DEFAULT_ERROR: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + securitySchemes: + sessionCookie: + type: apiKey + in: cookie + name: SESSION_COOKIE_NAME + description: Signed session cookie name is configured at runtime via SESSION_COOKIE_NAME + in the app environment. +servers: +- url: / diff --git a/bff/bff_app/routes/__init__.py b/bff/bff_app/routes/__init__.py new file mode 100644 index 00000000..cd2194df --- /dev/null +++ b/bff/bff_app/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP route modules.""" diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py new file mode 100644 index 00000000..a98f59ba --- /dev/null +++ b/bff/bff_app/routes/auth.py @@ -0,0 +1,303 @@ +"""Authentication endpoint routes for login, callback, logout and session checks.""" + +from __future__ import annotations + +import requests +import secrets +from authlib.integrations.requests_client import OAuth2Session +from flask import jsonify, redirect, request, session +from flask_smorest import Blueprint + +from bff_app.services.auth import get_settings, refresh_access_token + +auth_bp = Blueprint( + "auth", + __name__, + description="Authentication endpoints for login, callback, logout and session checks.", + url_prefix="/proxy/api/auth", +) + + +@auth_bp.route("/login", methods=["GET"]) +@auth_bp.doc( + summary="Start login flow", + description=( + "Creates a PKCE authorization URL and stores verifier/state in the session cookie." + ), + responses={ + "200": { + "description": "Auth server redirect URL.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"redirect": {"type": "string"}}, + "required": ["redirect"], + } + } + }, + } + }, +) +def login(): + """Start OAuth login by generating authorization URL and PKCE values. + + Side effects: + Stores the generated PKCE code verifier and OAuth state in the signed + Flask session cookie. + + :returns: + JSON payload containing the authorization redirect URL under + ``redirect`` key. + :rtype: flask.Response + """ + print("-> /proxy/api/auth/login") + settings = get_settings() + + client = OAuth2Session( + settings.oauth_client_id, + settings.oauth_client_secret, + scope=settings.oauth_oidc_scope, + code_challenge_method="S256", + redirect_uri=settings.oauth_login_redirect_uri, + ) + + code_verifier = secrets.token_hex(64) + uri, state = client.create_authorization_url( + settings.oauth_endpoint_authorization, + code_verifier=code_verifier, + ) + + session["cv"] = code_verifier + session["state"] = state + + return jsonify({"redirect": uri}) + + +@auth_bp.route("/callback", methods=["GET"]) +@auth_bp.doc( + summary="OAuth callback", + description="Exchanges the authorization code for tokens and stores them in the session.", + responses={ + "302": { + "description": "Redirects to frontend after successful or failed state check." + } + }, +) +def login_cb(): + """Handle OAuth callback and exchange authorization code for tokens. + + Request query parameters: + ``state`` and ``code`` as returned by the OAuth provider. + + Side effects: + Validates CSRF ``state`` and writes token payload to + ``session["token"]`` on success. + + :returns: + Redirect response to the configured frontend URL. + :rtype: flask.Response + :raises ValueError: + If required callback state information is missing. + """ + print("-> /proxy/api/auth/callback") + settings = get_settings() + + try: + if "state" not in session: + raise ValueError("State not found in cookie, cookie might be missing") + if "state" not in request.args: + raise ValueError("State not found in request arguments, incorrect request") + if request.args["state"] != session["state"]: + print("State mismatch") + return redirect(settings.frontend_redirect, code=302) + + print("State match !") + print("Performing Code Exchange") + + client = OAuth2Session( + settings.oauth_client_id, + settings.oauth_client_secret, + scope=settings.oauth_oidc_scope, + code_challenge_method="S256", + redirect_uri=settings.oauth_login_redirect_uri, + ) + + authorization_response = request.url + print("Authorization Code : ", authorization_response) + + token = client.fetch_token( + settings.oauth_endpoint_token, + authorization_response=authorization_response, + code_verifier=session["cv"], + ) + session["token"] = token + + return redirect(settings.frontend_redirect, code=302) + except Exception as exc: + print(exc) + raise exc + + +@auth_bp.route("/logout", methods=["GET"]) +@auth_bp.doc( + summary="Logout and revoke tokens", + description="Log out from auth server and clear local session data.", + security=[{"sessionCookie": []}], + responses={ + "200": { + "description": "Logout successful.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + } + } + }, + } + }, +) +def logout(): + """Log out from the auth server and clear local session data. + + :returns: + JSON payload confirming logout success. + :rtype: flask.Response + """ + print("-> /proxy/api/auth/logout") + settings = get_settings() + + requests.post( + settings.oauth_endpoint_logout, + {"id_token_hint": session["token"]["id_token"]}, + ) + session.clear() + return jsonify({"message": "logout successful"}) + + +@auth_bp.route("/userinfo", methods=["GET"]) +@auth_bp.doc( + summary="Fetch user info", + description="Proxy userinfo endpoint for the authenticated session.", + security=[{"sessionCookie": []}], + responses={ + "200": { + "description": "User info from the auth server.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": {"type": "string"}, + "email_verified": {"type": "boolean"}, + "family_name": {"type": "string"}, + "given_name": {"type": "string"}, + "name": {"type": "string"}, + "preferred_username": {"type": "string"}, + "sub": {"type": "string"}, + }, + "required": [ + "email", + "email_verified", + "family_name", + "given_name", + "name", + "preferred_username", + "sub", + ], + }, + "examples": { + "default": { + "value": { + "email": "olivia.diaz@embergenpower.demo", + "email_verified": True, + "family_name": "Diaz", + "given_name": "Olivia", + "name": "Olivia Diaz", + "preferred_username": "olivia.diaz@embergenpower.demo", + "sub": "8b8135af-4555-455c-bf84-567f99ff9e96", + } + } + }, + } + }, + } + }, +) +def auth_userinfo(): + """Proxy the OAuth userinfo endpoint for the authenticated session. + + Requires: + ``session["token"]["access_token"]`` to be present. + + :returns: + JSON object returned by the upstream userinfo endpoint. + :rtype: dict + """ + print("-> /proxy/api/auth/userinfo") + settings = get_settings() + + userinfo = requests.get( + settings.oauth_endpoint_userinfo, + headers={"Authorization": f"Bearer {session['token']['access_token']}"}, + ) + return userinfo.json() + + +@auth_bp.route("/session", methods=["GET"]) +@auth_bp.doc( + summary="Check session validity", + description="Validate whether the current browser session is still authenticated.", + security=[{"sessionCookie": []}], + responses={ + "200": { + "description": "Session state.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"session": {"type": "boolean"}}, + "required": ["session"], + } + } + }, + } + }, +) +def check_session(): + """Validate whether the current browser session is still authenticated. + + Flow: + 1. Call the userinfo endpoint with the stored access token. + 2. If token is invalid, attempt refresh via refresh token. + 3. Re-check userinfo after successful refresh. + 4. Clear session when no valid auth state remains. + + :returns: + JSON object containing ``{"session": }``. + :rtype: flask.Response + """ + print("-> /proxy/api/auth/session") + settings = get_settings() + + is_valid_session = False + + if "token" in session: + userinfo = requests.get( + settings.oauth_endpoint_userinfo, + headers={"Authorization": f"Bearer {session['token']['access_token']}"}, + ) + is_valid_session = userinfo.status_code == 200 + if not is_valid_session and refresh_access_token(): + userinfo = requests.get( + settings.oauth_endpoint_userinfo, + headers={"Authorization": f"Bearer {session['token']['access_token']}"}, + ) + is_valid_session = userinfo.status_code == 200 + + if not is_valid_session: + session.clear() + + return jsonify({"session": is_valid_session}) diff --git a/bff/bff_app/routes/health.py b/bff/bff_app/routes/health.py new file mode 100644 index 00000000..86f85477 --- /dev/null +++ b/bff/bff_app/routes/health.py @@ -0,0 +1,41 @@ +"""Healthcheck routes.""" + +from __future__ import annotations + +from flask import jsonify +from flask_smorest import Blueprint + +health_bp = Blueprint( + "health", + __name__, + description="Healthcheck endpoints.", +) + + +@health_bp.route("/ping", methods=["GET"]) +@health_bp.doc( + summary="Health check", + description="Return a basic healthcheck response.", + responses={ + "200": { + "description": "Pong response.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + } + } + }, + } + }, +) +def ping(): + """Return a basic healthcheck response. + + :returns: ``{"message": "pong"}``. + :rtype: flask.Response + """ + print("-> /ping") + return jsonify({"message": "pong"}) diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py new file mode 100644 index 00000000..d6650dd7 --- /dev/null +++ b/bff/bff_app/routes/proxy.py @@ -0,0 +1,116 @@ +"""Backend proxy endpoint routes.""" + +from __future__ import annotations + +import requests +from flask import Response, request, session +from flask_smorest import Blueprint + +from bff_app.services.auth import get_settings, refresh_access_token + +proxy_bp = Blueprint( + "proxy", + __name__, + description="Backend passthrough proxy endpoints.", +) + + +@proxy_bp.route( + "/proxy/api/request/", + methods=["OPTIONS"], +) +@proxy_bp.doc( + summary="REST preflight", + responses={"204": {"description": "Preflight response."}}, +) +def proxy_preflight(rest_of_url: str): + """Handle CORS preflight for the proxy endpoint.""" + _ = rest_of_url + return Response(status=204) + + +@proxy_bp.route( + "/proxy/api/request/", + methods=["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"], +) +@proxy_bp.doc( + summary="Proxy REST request", + description="Forward the incoming request to BACKEND_ENDPOINT with auth token passthrough.", + security=[{"sessionCookie": []}], + responses={ + "200": { + "description": "Proxied response from REST backend.", + "content": { + "*/*": {"schema": {"type": "string", "format": "binary"}}, + }, + }, + "502": {"description": "Upstream connection error."}, + }, +) +def proxy_request(rest_of_url: str): + """Forward an incoming request to the configured backend API. + + :param rest_of_url: + Path segment appended to ``BACKEND_ENDPOINT`` to build the target URL. + :type rest_of_url: str + :returns: + Upstream response body/status/headers adapted for the frontend client. + :rtype: flask.Response + + Behavior: + - Handles CORS preflight by returning ``204`` on ``OPTIONS``. + - Injects bearer access token from session when available. + - Retries once after token refresh when upstream returns + ``401`` with ``invalid_token``. + - Drops hop-by-hop and duplicate CORS headers from upstream response. + """ + print("-> /proxy/api/request/" + rest_of_url) + settings = get_settings() + + headers = {k: v for k, v in request.headers if k.lower() != "host"} + payload = request.get_data() + + if "token" in session and "access_token" in session["token"]: + headers["Authorization"] = f"Bearer {session['token']['access_token']}" + else: + print("ACCESS TOKEN IS MISSING") + + target_url = f"{settings.backend_endpoint.rstrip('/')}/{rest_of_url.lstrip('/')}" + + def forward_request(): + return requests.request( + method=request.method, + url=target_url, + headers=headers, + params=request.args, + data=payload, + allow_redirects=False, + ) + + try: + response = forward_request() + except requests.exceptions.RequestException as exc: + print(f"REST proxy error: {exc}") + return Response("Upstream connection error", status=502) + + www_authenticate = response.headers.get("www-authenticate", "") + if response.status_code == 401 and "invalid_token" in www_authenticate: + if refresh_access_token(): + headers["Authorization"] = f"Bearer {session['token']['access_token']}" + try: + response = forward_request() + except requests.exceptions.RequestException as exc: + print(f"REST proxy error after refresh: {exc}") + return Response("Upstream connection error", status=502) + + excluded_headers = [ + "transfer-encoding", + "access-control-allow-origin", + ] + filtered_headers = [ + (k, v) + for k, v in response.headers.items() + if k.lower() not in excluded_headers + ] + + return Response(response.content, response.status_code, filtered_headers) diff --git a/bff/bff_app/services/__init__.py b/bff/bff_app/services/__init__.py new file mode 100644 index 00000000..60ea8d30 --- /dev/null +++ b/bff/bff_app/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for BFF business logic.""" diff --git a/bff/bff_app/services/auth.py b/bff/bff_app/services/auth.py new file mode 100644 index 00000000..21b8ae82 --- /dev/null +++ b/bff/bff_app/services/auth.py @@ -0,0 +1,56 @@ +"""Authentication-related service helpers shared by route handlers.""" + +from __future__ import annotations + +import requests +from flask import current_app, session + +from bff_app.settings import BffSettings + + +def get_settings() -> BffSettings: + """Return resolved application settings from Flask extensions. + + :returns: Current app settings object. + :rtype: BffSettings + """ + return current_app.extensions["bff_settings"] + + +def refresh_access_token() -> bool: + """Refresh the access token using the current session refresh token. + + The function updates ``session["token"]`` with the token payload returned + by the OAuth token endpoint. + + :returns: + ``True`` when the token refresh succeeds, otherwise ``False``. + :rtype: bool + """ + settings = get_settings() + + refresh_token = session.get("token", {}).get("refresh_token") + if not refresh_token: + print("REFRESH TOKEN IS MISSING") + return False + + try: + response = requests.post( + settings.oauth_endpoint_token, + data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": settings.oauth_client_id, + "client_secret": settings.oauth_client_secret, + }, + ) + except requests.exceptions.RequestException as exc: + print(f"REFRESH TOKEN REQUEST FAILED: {exc}") + return False + + if response.status_code != 200: + print(f"REFRESH TOKEN REQUEST REJECTED: {response.status_code}") + return False + + session["token"] = response.json() + return True diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py new file mode 100644 index 00000000..b23db43b --- /dev/null +++ b/bff/bff_app/settings.py @@ -0,0 +1,88 @@ +"""Application configuration objects and environment loading helpers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _env_bool(name: str, default: bool) -> bool: + """Parse a boolean environment variable. + + :param name: Environment variable name. + :param default: Value returned when the environment variable is absent. + :returns: Parsed boolean value. + :rtype: bool + """ + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class BffSettings: + """Immutable runtime settings used by endpoint handlers. + + :ivar flask_secret_key: Secret key used to sign the Flask session cookie. + :ivar session_cookie_name: Name of the session cookie. + :ivar session_cookie_path: Path scope of the session cookie. + :ivar session_cookie_httponly: Whether JavaScript access to the cookie is disabled. + :ivar session_cookie_secure: Whether cookie transport is restricted to HTTPS. + :ivar session_cookie_samesite: SameSite policy applied to the session cookie. + :ivar cors_allowed_origin: Allowed CORS origin for browser requests. + :ivar backend_endpoint: Base URL of the proxied backend API. + :ivar oauth_client_id: OAuth client identifier. + :ivar oauth_client_secret: OAuth client secret. + :ivar oauth_oidc_scope: Requested OIDC scope. + :ivar oauth_endpoint_authorization: OAuth authorization endpoint URL. + :ivar oauth_endpoint_token: OAuth token endpoint URL. + :ivar oauth_endpoint_userinfo: OAuth userinfo endpoint URL. + :ivar oauth_endpoint_logout: OAuth logout endpoint URL. + :ivar oauth_login_redirect_uri: Redirect URI handled by the BFF callback. + :ivar frontend_redirect: Frontend URL used after login callback. + """ + flask_secret_key: str | None + session_cookie_name: str | None + session_cookie_path: str + session_cookie_httponly: bool + session_cookie_secure: bool + session_cookie_samesite: str + cors_allowed_origin: str | None + backend_endpoint: str | None + oauth_client_id: str | None + oauth_client_secret: str | None + oauth_oidc_scope: str | None + oauth_endpoint_authorization: str | None + oauth_endpoint_token: str | None + oauth_endpoint_userinfo: str | None + oauth_endpoint_logout: str | None + oauth_login_redirect_uri: str | None + frontend_redirect: str | None + + +def load_settings_from_env() -> BffSettings: + """Build :class:`BffSettings` from process environment variables. + + :returns: Environment-derived application settings. + :rtype: BffSettings + """ + return BffSettings( + flask_secret_key=os.getenv("FLASK_SECRET_KEY"), + session_cookie_name=os.getenv("SESSION_COOKIE_NAME"), + session_cookie_path=os.getenv("SESSION_COOKIE_PATH", "/"), + session_cookie_httponly=_env_bool("SESSION_COOKIE_HTTPONLY", True), + session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE", True), + session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "Strict"), + cors_allowed_origin=os.getenv("CORS_ALLOWED_ORIGIN"), + backend_endpoint=os.getenv("BACKEND_ENDPOINT"), + oauth_client_id=os.getenv("OAUTH_CLIENT_ID"), + oauth_client_secret=os.getenv("OAUTH_CLIENT_SECRET"), + oauth_oidc_scope=os.getenv("OAUTH_OIDC_SCOPE"), + oauth_endpoint_authorization=os.getenv("OAUTH_ENDPOINT_AUTHORIZATION"), + oauth_endpoint_token=os.getenv("OAUTH_ENDPOINT_TOKEN"), + oauth_endpoint_userinfo=os.getenv("OAUTH_ENDPOINT_USERINFO"), + oauth_endpoint_logout=os.getenv("OAUTH_ENDPOINT_LOGOUT"), + oauth_login_redirect_uri=os.getenv("OAUTH_LOGIN_REDIRECT_URI"), + frontend_redirect=os.getenv("FRONTEND_REDIRECT"), + ) diff --git a/bff/docker-compose.yml b/bff/docker-compose.yml new file mode 100644 index 00000000..9e9d9835 --- /dev/null +++ b/bff/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3' + +services: + web: + build: + context: . + dockerfile: Dockerfile + args: + PORT: ${PORT} + ports: + - "${PORT}:${PORT}" + env_file: + - .env + container_name: bff-flask diff --git a/bff/pyproject.toml b/bff/pyproject.toml new file mode 100644 index 00000000..6a79326a --- /dev/null +++ b/bff/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "backend-for-frontend" +version = "0.3.0" +description = "Flask backend-for-frontend" +requires-python = ">=3.12,<3.13" +dependencies = [ + "authlib", + "flask~=3.1.2", + "flask-cors", + "flask-session", + "flask-smorest>=0.46.2", + "pyyaml~=6.0", + "python-dotenv~=1.2.1", + "redis", + "requests", +] + +[dependency-groups] +dev = [ + "pytest", +] diff --git a/bff/tests/conftest.py b/bff/tests/conftest.py new file mode 100644 index 00000000..b9c9fa4b --- /dev/null +++ b/bff/tests/conftest.py @@ -0,0 +1,50 @@ +import sys +from pathlib import Path + +import pytest + +# Ensure the project root is on sys.path so local imports resolve in pytest. +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +@pytest.fixture() +def app(monkeypatch): + """ + Build a Flask app with predictable env vars so tests don't depend on a local .env. + """ + # Core Flask config + monkeypatch.setenv("FLASK_SECRET_KEY", "test-secret") + monkeypatch.setenv("SESSION_COOKIE_NAME", "test-session") + monkeypatch.setenv("SESSION_COOKIE_PATH", "/") + monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") + monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") + monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") + monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://example.test") + + # Backend proxy configuration + monkeypatch.setenv("BACKEND_ENDPOINT", "http://backend.test/api") + + # OAuth config + monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id") + monkeypatch.setenv("OAUTH_CLIENT_SECRET", "client-secret") + monkeypatch.setenv("OAUTH_OIDC_SCOPE", "openid profile") + monkeypatch.setenv("OAUTH_ENDPOINT_AUTHORIZATION", "http://auth.test/authorize") + monkeypatch.setenv("OAUTH_ENDPOINT_TOKEN", "http://auth.test/token") + monkeypatch.setenv("OAUTH_ENDPOINT_USERINFO", "http://auth.test/userinfo") + monkeypatch.setenv("OAUTH_ENDPOINT_LOGOUT", "http://auth.test/logout") + monkeypatch.setenv("OAUTH_LOGIN_REDIRECT_URI", "http://app.test/proxy/api/auth/callback") + monkeypatch.setenv("FRONTEND_REDIRECT", "http://frontend.test") + + from bff_app import create_app + from bff_app.settings import load_settings_from_env + + flask_app = create_app(load_settings_from_env()) + flask_app.config["TESTING"] = True + return flask_app + + +@pytest.fixture() +def client(app): + return app.test_client() diff --git a/bff/tests/test_auth_callback.py b/bff/tests/test_auth_callback.py new file mode 100644 index 00000000..47f8888b --- /dev/null +++ b/bff/tests/test_auth_callback.py @@ -0,0 +1,46 @@ +from unittest.mock import MagicMock + +from bff_app.routes import auth as auth_routes + + +def _fake_oauth_session(state="state-123", token=None): + """ + Build a fake OAuth2 session with deterministic responses. + """ + token = token or {"access_token": "access-token", "id_token": "id-token"} + fake = MagicMock() + fake.create_authorization_url.return_value = ("http://auth.test/login", state) + fake.fetch_token.return_value = token + return fake + + +def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): + # Mock the token exchange so we don't call the real auth server. + fake_oauth = _fake_oauth_session(token={"access_token": "tok", "id_token": "id"}) + monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) + + # Pre-populate session with state and PKCE verifier to match the callback request. + with client.session_transaction() as sess: + sess["state"] = "state-123" + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?state=state-123&code=abc") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test" + + # Token should be saved to the session after the code exchange. + with client.session_transaction() as sess: + assert sess["token"]["access_token"] == "tok" + + +def test_login_callback_state_mismatch_redirects(client): + # State mismatch short-circuits without token exchange. + with client.session_transaction() as sess: + sess["state"] = "state-123" + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?state=wrong&code=abc") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test" diff --git a/bff/tests/test_auth_login.py b/bff/tests/test_auth_login.py new file mode 100644 index 00000000..bdfe2c69 --- /dev/null +++ b/bff/tests/test_auth_login.py @@ -0,0 +1,29 @@ +from unittest.mock import MagicMock + +from bff_app.routes import auth as auth_routes + + +def _fake_oauth_session(expected_auth_url="http://auth.test/login", state="state-123"): + """ + Build a fake OAuth2 session with deterministic responses. + """ + fake = MagicMock() + fake.create_authorization_url.return_value = (expected_auth_url, state) + return fake + + +def test_login_sets_session_and_returns_redirect(client, monkeypatch): + # Mock OAuth flow so we don't hit a real auth server. + fake_oauth = _fake_oauth_session() + monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) + monkeypatch.setattr(auth_routes.secrets, "token_hex", lambda n: "cv-hex") + + res = client.get("/proxy/api/auth/login") + + assert res.status_code == 200 + assert res.get_json() == {"redirect": "http://auth.test/login"} + + # Ensure the PKCE verifier and state are stored in the signed session cookie. + with client.session_transaction() as sess: + assert sess["cv"] == "cv-hex" + assert sess["state"] == "state-123" diff --git a/bff/tests/test_auth_logout.py b/bff/tests/test_auth_logout.py new file mode 100644 index 00000000..580517ac --- /dev/null +++ b/bff/tests/test_auth_logout.py @@ -0,0 +1,23 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from bff_app.routes import auth as auth_routes + + +def test_logout_revokes_tokens_and_clears_session(client, monkeypatch): + # Mock the auth server logout call. + mock_post = MagicMock(return_value=SimpleNamespace(status_code=200)) + monkeypatch.setattr(auth_routes.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["token"] = {"id_token": "id-token"} + + res = client.get("/proxy/api/auth/logout") + + assert res.status_code == 200 + assert res.get_json() == {"message": "logout successful"} + mock_post.assert_called_once() + + # Session should be empty after logout. + with client.session_transaction() as sess: + assert "token" not in sess diff --git a/bff/tests/test_auth_session.py b/bff/tests/test_auth_session.py new file mode 100644 index 00000000..5fee4580 --- /dev/null +++ b/bff/tests/test_auth_session.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from bff_app.routes import auth as auth_routes +from bff_app.services import auth as auth_service + + +def test_session_true_when_userinfo_ok(client, monkeypatch): + # If userinfo returns 200, the session is considered valid. + mock_get = MagicMock(return_value=SimpleNamespace(status_code=200)) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "access-token"} + + res = client.get("/proxy/api/auth/session") + + assert res.status_code == 200 + assert res.get_json() == {"session": True} + + +def test_session_refreshes_and_recovers(client, monkeypatch): + mock_get = MagicMock(side_effect=[ + SimpleNamespace(status_code=401), + SimpleNamespace(status_code=200), + ]) + mock_post = MagicMock(return_value=SimpleNamespace( + status_code=200, + json=lambda: { + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + }, + )) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + monkeypatch.setattr(auth_service.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["token"] = { + "access_token": "access-token", + "refresh_token": "refresh-token", + } + + res = client.get("/proxy/api/auth/session") + + assert res.status_code == 200 + assert res.get_json() == {"session": True} + + +def test_session_false_clears_cookie_on_failure(client, monkeypatch): + # Non-200 from userinfo causes the session to be cleared. + mock_get = MagicMock(return_value=SimpleNamespace(status_code=401)) + mock_post = MagicMock(return_value=SimpleNamespace(status_code=400)) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + monkeypatch.setattr(auth_service.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["token"] = { + "access_token": "access-token", + "refresh_token": "refresh-token", + } + + res = client.get("/proxy/api/auth/session") + + assert res.status_code == 200 + assert res.get_json() == {"session": False} + + with client.session_transaction() as sess: + assert "token" not in sess diff --git a/bff/tests/test_auth_userinfo.py b/bff/tests/test_auth_userinfo.py new file mode 100644 index 00000000..4472bf5a --- /dev/null +++ b/bff/tests/test_auth_userinfo.py @@ -0,0 +1,19 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from bff_app.routes import auth as auth_routes + + +def test_userinfo_proxies_to_auth_server(client, monkeypatch): + # Mock userinfo response from auth server. + mock_get = MagicMock(return_value=SimpleNamespace(json=lambda: {"sub": "user-1"})) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "access-token"} + + res = client.get("/proxy/api/auth/userinfo") + + assert res.status_code == 200 + assert res.get_json() == {"sub": "user-1"} + mock_get.assert_called_once() diff --git a/bff/tests/test_openapi_generation.py b/bff/tests/test_openapi_generation.py new file mode 100644 index 00000000..44895256 --- /dev/null +++ b/bff/tests/test_openapi_generation.py @@ -0,0 +1,47 @@ +from pathlib import Path +import sys + +import yaml + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from bff_app.openapi.generate import build_openapi_document, main + + +def test_generation_contains_expected_paths(tmp_path: Path) -> None: + output = tmp_path / "openapi.yaml" + + assert main(["--output", str(output)]) == 0 + document = yaml.safe_load(output.read_text(encoding="utf-8")) + paths = document["paths"] + + assert "/proxy/api/request/{rest_of_url}" in paths + assert "/proxy/request/{rest_of_url}" not in paths + assert "/proxy/api/auth/login" in paths + assert "/proxy/api/auth/callback" in paths + assert "/proxy/api/auth/logout" in paths + assert "/proxy/api/auth/userinfo" in paths + assert "/proxy/api/auth/session" in paths + assert "/ping" in paths + + +def test_decorator_docs_are_exported() -> None: + document = build_openapi_document() + + assert document["openapi"] == "3.0.3" + assert document["paths"]["/proxy/api/auth/login"]["get"]["summary"] == "Start login flow" + assert "sessionCookie" in document["components"]["securitySchemes"] + + +def test_check_mode_reports_drift(tmp_path: Path) -> None: + output = tmp_path / "openapi.yaml" + + assert main(["--output", str(output)]) == 0 + assert main(["--check", "--output", str(output)]) == 0 + + output.write_text(output.read_text(encoding="utf-8") + "\n# drift\n", encoding="utf-8") + + assert main(["--check", "--output", str(output)]) == 1 + diff --git a/bff/tests/test_ping.py b/bff/tests/test_ping.py new file mode 100644 index 00000000..8ed90c27 --- /dev/null +++ b/bff/tests/test_ping.py @@ -0,0 +1,5 @@ +def test_ping_endpoint(client): + # Simple health check to verify the app is alive. + res = client.get("/ping") + assert res.status_code == 200 + assert res.get_json() == {"message": "pong"} diff --git a/bff/tests/test_proxy_request.py b/bff/tests/test_proxy_request.py new file mode 100644 index 00000000..e0d53687 --- /dev/null +++ b/bff/tests/test_proxy_request.py @@ -0,0 +1,79 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from bff_app.routes import proxy as proxy_routes +from bff_app.services import auth as auth_service + + +def test_proxy_request_forwards_to_backend(client, monkeypatch): + # Mock backend response so we avoid a real HTTP call. + backend_response = SimpleNamespace( + content=b'{"ok":true}', + status_code=200, + headers={ + "content-type": "application/json", + "transfer-encoding": "chunked", + "access-control-allow-origin": "*", + }, + ) + mock_request = MagicMock(return_value=backend_response) + monkeypatch.setattr(proxy_routes.requests, "request", mock_request) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "access-token"} + + res = client.post("/proxy/api/request/widgets?limit=5", data=b'{"x":1}') + + assert res.status_code == 200 + assert res.data == b'{"ok":true}' + # Ensure the upstream call targeted the configured backend base URL. + assert mock_request.call_args.kwargs["url"] == "http://backend.test/api/widgets" + assert dict(mock_request.call_args.kwargs["params"]) == {"limit": "5"} + # Flask should drop hop-by-hop headers from the upstream response. + assert "transfer-encoding" not in res.headers + # CORS header should be set by flask_cors, not forwarded from upstream. + assert res.headers["Access-Control-Allow-Origin"] == "http://example.test" + + +def test_proxy_request_options_short_circuits(client): + # Preflight request should return 204 without calling the backend. + res = client.open("/proxy/api/request/widgets", method="OPTIONS") + assert res.status_code == 204 + + +def test_proxy_request_retries_on_invalid_token(client, monkeypatch): + backend_response = SimpleNamespace( + content=b'{"ok":true}', + status_code=200, + headers={ + "content-type": "application/json", + }, + ) + unauthorized_response = SimpleNamespace( + content=b'{"error":"invalid_token"}', + status_code=401, + headers={"www-authenticate": 'Bearer error="invalid_token"'}, + ) + mock_request = MagicMock(side_effect=[unauthorized_response, backend_response]) + monkeypatch.setattr(proxy_routes.requests, "request", mock_request) + + mock_post = MagicMock(return_value=SimpleNamespace( + status_code=200, + json=lambda: { + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + }, + )) + monkeypatch.setattr(auth_service.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["token"] = { + "access_token": "access-token", + "refresh_token": "refresh-token", + } + + res = client.get("/proxy/api/request/widgets") + + assert res.status_code == 200 + assert res.data == b'{"ok":true}' + assert mock_request.call_count == 2 diff --git a/bff/uv.lock b/bff/uv.lock new file mode 100644 index 00000000..378af92c --- /dev/null +++ b/bff/uv.lock @@ -0,0 +1,484 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "apispec" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/ad/30cd449f3a0cf213dd13d9af7ba869214d8c66d517939964d3f490307e46/apispec-6.9.0.tar.gz", hash = "sha256:7a38ce7c3eedc7771e6e33295afdd8c4b0acdd9865b483f8cf6cc369c93e8d1e", size = 77846, upload-time = "2025-11-30T22:31:49.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/eb/d1dc13f3b2f9985777526c36096d9595ae0fa7ee7ff5e593abefe1636939/apispec-6.9.0-py3-none-any.whl", hash = "sha256:4c275f0a6dac0bcfcceee00b451a16b650f9184a57c624b0b6d12d82b8d15a61", size = 30640, upload-time = "2025-11-30T22:31:48.384Z" }, +] + +[package.optional-dependencies] +marshmallow = [ + { name = "marshmallow" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "backend-for-frontend" +version = "0.3.0" +source = { virtual = "." } +dependencies = [ + { name = "authlib" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-session" }, + { name = "flask-smorest" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "authlib" }, + { name = "flask", specifier = "~=3.1.2" }, + { name = "flask-cors" }, + { name = "flask-session" }, + { name = "flask-smorest", specifier = ">=0.46.2" }, + { name = "python-dotenv", specifier = "~=1.2.1" }, + { name = "pyyaml", specifier = "~=6.0" }, + { name = "redis" }, + { name = "requests" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachelib" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/69/0b5c1259e12fbcf5c2abe5934b5c0c1294ec0f845e2b4b2a51a91d79a4fb/cachelib-0.13.0.tar.gz", hash = "sha256:209d8996e3c57595bee274ff97116d1d73c4980b2fd9a34c7846cd07fd2e1a48", size = 34418, upload-time = "2024-04-13T14:18:27.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/42/960fc9896ddeb301716fdd554bab7941c35fb90a1dc7260b77df3366f87f/cachelib-0.13.0-py3-none-any.whl", hash = "sha256:8c8019e53b6302967d4e8329a504acf75e7bc46130291d30188a6e4e58162516", size = 20914, upload-time = "2024-04-13T14:18:26.361Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "flask-cors" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, +] + +[[package]] +name = "flask-session" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachelib" }, + { name = "flask" }, + { name = "msgspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/d7/0ba4180513abe28eadc208123c76f9f09e290d5939fb2eb68323b9733354/flask_session-0.8.0.tar.gz", hash = "sha256:20e045eb01103694e70be4a49f3a80dbb1b57296a22dc6f44bbf3f83ef0742ff", size = 940269, upload-time = "2024-03-26T07:56:13.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/1b/f085ceebb825d1cfaf078852b67cd248a33af2905f40ba9860cc006d966b/flask_session-0.8.0-py3-none-any.whl", hash = "sha256:5dae6e9ddab334f8dc4dea4305af37851f4e7dc0f484caf3351184001195e3b7", size = 24410, upload-time = "2024-03-26T07:56:11.377Z" }, +] + +[[package]] +name = "flask-smorest" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "apispec", extra = ["marshmallow"] }, + { name = "flask" }, + { name = "marshmallow" }, + { name = "webargs" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/09/70074c15a9644a1def601b4fc78c114d4b2f4275310bc3476d1411da3003/flask_smorest-0.46.2.tar.gz", hash = "sha256:7d9099a37a5add415670d6904ae032eefc1d2ba2432f060d7c048c74fc655a80", size = 77421, upload-time = "2025-09-10T20:18:26.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/92/f5176b1bfc672c4d91c5ca70941c72e1e61bd28bce53037680c1e79be330/flask_smorest-0.46.2-py3-none-any.whl", hash = "sha256:caff5b95d575044f90854c70a218d4c05bc920e462fdca2c58c0dd48ce29946c", size = 32329, upload-time = "2025-09-10T20:18:25.58Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "marshmallow" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/03/261af5efb3d3ce0e2db3fd1e11dc5a96b74a4fb76e488da1c845a8f12345/marshmallow-4.2.2.tar.gz", hash = "sha256:ba40340683a2d1c15103647994ff2f6bc2c8c80da01904cbe5d96ee4baa78d9f", size = 221404, upload-time = "2026-02-04T15:47:03.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/70/bb89f807a6a6704bdc4d6f850d5d32954f6c1965e3248e31455defdf2f30/marshmallow-4.2.2-py3-none-any.whl", hash = "sha256:084a9466111b7ec7183ca3a65aed758739af919fedc5ebdab60fb39d6b4dc121", size = 48454, upload-time = "2026-02-04T15:47:02.013Z" }, +] + +[[package]] +name = "msgspec" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9c/bfbd12955a49180cbd234c5d29ec6f74fe641698f0cd9df154a854fc8a15/msgspec-0.20.0.tar.gz", hash = "sha256:692349e588fde322875f8d3025ac01689fead5901e7fb18d6870a44519d62a29", size = 317862, upload-time = "2025-11-24T03:56:28.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/6f/1e25eee957e58e3afb2a44b94fa95e06cebc4c236193ed0de3012fff1e19/msgspec-0.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2aba22e2e302e9231e85edc24f27ba1f524d43c223ef5765bd8624c7df9ec0a5", size = 196391, upload-time = "2025-11-24T03:55:32.677Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/af51d090ada641d4b264992a486435ba3ef5b5634bc27e6eb002f71cef7d/msgspec-0.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:716284f898ab2547fedd72a93bb940375de9fbfe77538f05779632dc34afdfde", size = 188644, upload-time = "2025-11-24T03:55:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/9709ee093b7742362c2934bfb1bbe791a1e09bed3ea5d8a18ce552fbfd73/msgspec-0.20.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:558ed73315efa51b1538fa8f1d3b22c8c5ff6d9a2a62eff87d25829b94fc5054", size = 218852, upload-time = "2025-11-24T03:55:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/488517a43ccf5a4b6b6eca6dd4ede0bd82b043d1539dd6bb908a19f8efd3/msgspec-0.20.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:509ac1362a1d53aa66798c9b9fd76872d7faa30fcf89b2fba3bcbfd559d56eb0", size = 224937, upload-time = "2025-11-24T03:55:36.859Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e8/49b832808aa23b85d4f090d1d2e48a4e3834871415031ed7c5fe48723156/msgspec-0.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1353c2c93423602e7dea1aa4c92f3391fdfc25ff40e0bacf81d34dbc68adb870", size = 222858, upload-time = "2025-11-24T03:55:38.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/56/1dc2fa53685dca9c3f243a6cbecd34e856858354e455b77f47ebd76cf5bf/msgspec-0.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb33b5eb5adb3c33d749684471c6a165468395d7aa02d8867c15103b81e1da3e", size = 227248, upload-time = "2025-11-24T03:55:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/5a/51/aba940212c23b32eedce752896205912c2668472ed5b205fc33da28a6509/msgspec-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:fb1d934e435dd3a2b8cf4bbf47a8757100b4a1cfdc2afdf227541199885cdacb", size = 190024, upload-time = "2025-11-24T03:55:40.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/3b9f259d94f183daa9764fef33fdc7010f7ecffc29af977044fa47440a83/msgspec-0.20.0-cp312-cp312-win_arm64.whl", hash = "sha256:00648b1e19cf01b2be45444ba9dc961bd4c056ffb15706651e64e5d6ec6197b7", size = 175390, upload-time = "2025-11-24T03:55:42.05Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +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 = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "redis" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "webargs" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/64/17afc4e6f47eef154a553c6e56adcc9f1ac3003305c7df978d11aa62937e/webargs-8.7.1.tar.gz", hash = "sha256:799bf9039c76c23fd8dc1951107a75a9e561203c15d6ae8f89c1e46e234636c1", size = 97351, upload-time = "2025-10-29T16:07:50.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/ef/b0d17f3943429358184449771b592e0e1d33bbeaa6ed326434a95eac187b/webargs-8.7.1-py3-none-any.whl", hash = "sha256:a184aed9d2509e6e14ab99ee3e9dc3a614c7070affe94cd4dfdb0d002e0a6e5f", size = 32500, upload-time = "2025-10-29T16:07:47.895Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] From 265ab4749fb68ae62f96e7e77bf3b9a824075c77 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Tue, 3 Mar 2026 16:30:07 +0100 Subject: [PATCH 02/24] Add `.env.example` template and update `.gitignore` - Provide `.env.example` with default environment variables for BFF service configuration. - Adjust `.gitignore` to exclude `.env` files for ensuring local environment secrecy. --- bff/.env.example | 34 ++++++++++++++++++++++++++++++++++ bff/.gitignore | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 bff/.env.example diff --git a/bff/.env.example b/bff/.env.example new file mode 100644 index 00000000..7836be5f --- /dev/null +++ b/bff/.env.example @@ -0,0 +1,34 @@ +# Deployment +FLASK_ENV=production +PORT=5022 + +# Flask secret, used to sign session cookie +FLASK_SECRET_KEY="replace-me" + +# OAuth Client and Secret, both are sensitive data +OAUTH_CLIENT_ID="replace-me" +OAUTH_CLIENT_SECRET="replace-me" + +# OAuth server endpoints +OAUTH_ENDPOINT_AUTHORIZATION="http://localhost:8785/realms/mms/protocol/openid-connect/auth" +OAUTH_ENDPOINT_TOKEN="http://localhost:8785/realms/mms/protocol/openid-connect/token" +OAUTH_ENDPOINT_USERINFO="http://localhost:8785/realms/mms/protocol/openid-connect/userinfo" +OAUTH_ENDPOINT_LOGOUT="http://localhost:8785/realms/mms/protocol/openid-connect/logout" + +# OAuth OIDC scope +OAUTH_OIDC_SCOPE="openid mms-local" + +# BFF URL redirect after successful login +OAUTH_LOGIN_REDIRECT_URI="http://localhost:5022/proxy/api/auth/callback" + +# Frontend origin and redirect after login +CORS_ALLOWED_ORIGIN="http://localhost:5178" +FRONTEND_REDIRECT="http://localhost:5178" + +# Session cookie +SESSION_COOKIE_NAME="Fake__Host-session" # Must have __Host- prefix in a non-local env +SESSION_COOKIE_SECURE="True" +SESSION_COOKIE_SAMESITE="Strict" + +# Server we're proxying +BACKEND_ENDPOINT="http://host.docker.internal:8080/api" diff --git a/bff/.gitignore b/bff/.gitignore index 7d1d96fc..395fd78f 100644 --- a/bff/.gitignore +++ b/bff/.gitignore @@ -1,5 +1,5 @@ /.env -.env.* +.env .idea **/*/__pycache__ __pycache__/ From 992f38d1013c2fc5e87324cb25c9e69715a63bbd Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 4 Mar 2026 11:26:54 +0100 Subject: [PATCH 03/24] Add monorepo versioning orchestrator - Introduce `scripts/wefa_version.py` for unified version management across `vue`, `django`, and `bff` projects. - Update contribution and README guides to reference the new versioning workflow. - Enhance GitHub Actions to validate version alignment before publishing. - Standardize release processes and align tooling documentation. --- .github/workflows/release.yml | 17 ++ README.md | 15 ++ django/CONTRIBUTE.md | 3 +- scripts/wefa_version.py | 415 ++++++++++++++++++++++++++++++++++ vue/CONTRIBUTE.md | 6 +- vue/README.md | 8 +- 6 files changed, 460 insertions(+), 4 deletions(-) create mode 100644 scripts/wefa_version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f56ae739..1623dbd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,25 @@ permissions: contents: read jobs: + validate-version: + name: Validate monorepo version + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Validate shared version against release tag + run: python3 scripts/wefa_version.py check --expect "${{ github.event.release.tag_name }}" + publish-npm: name: Publish Vue package to npm runs-on: ubuntu-latest + needs: validate-version permissions: id-token: write defaults: @@ -46,6 +62,7 @@ jobs: publish-pypi: name: Publish Django package to PyPI runs-on: ubuntu-latest + needs: validate-version environment: name: pypi url: https://pypi.org/p/nside-wefa diff --git a/README.md b/README.md index 910f5f9c..6cfd964d 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,21 @@ See the [Vue README](vue/README.md) for build scripts, Storybook, and component The demo apps in both workspaces illustrate how to compose the packages together. +## Release Versioning + +This monorepo uses one shared version across `vue/`, `django/`, and `bff/`. +Use the root orchestrator script for any version change: + +```bash +python3 scripts/wefa_version.py help +python3 scripts/wefa_version.py show +python3 scripts/wefa_version.py check --expect +python3 scripts/wefa_version.py bump patch +python3 scripts/wefa_version.py set 1.0.0-rc.1 +``` + +Use `--dry-run` to preview changes and `--allow-dirty-version-files` only when you intentionally need to override preflight checks. + ## Contributing Contributions are welcome! Start with open issues or propose new ideas through GitHub discussions. Please read [Django CONTRIBUTE](django/CONTRIBUTE.md) and/or [Vue CONTRIBUTE](vue/CONTRIBUTE.md) for the current contribution workflow. diff --git a/django/CONTRIBUTE.md b/django/CONTRIBUTE.md index bfa409ef..165c16a8 100644 --- a/django/CONTRIBUTE.md +++ b/django/CONTRIBUTE.md @@ -337,6 +337,7 @@ When making changes: - Update the main README if adding new features - Keep examples up to date - Update version numbers as needed +- For monorepo releases, run version bumps from the repository root with `python3 scripts/wefa_version.py` ### API Documentation @@ -354,4 +355,4 @@ If you have questions or need help: 3. Create a new issue with detailed information 4. Tag maintainers if urgent -Thank you for contributing to N-SIDE WeFa! \ No newline at end of file +Thank you for contributing to N-SIDE WeFa! diff --git a/scripts/wefa_version.py b/scripts/wefa_version.py new file mode 100644 index 00000000..f150c6c1 --- /dev/null +++ b/scripts/wefa_version.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Unified monorepo version orchestrator for vue, django, and bff.""" + +from __future__ import annotations + +import argparse +import json +import re +import shlex +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +ROOT = Path(__file__).resolve().parent.parent + +SEMVER_RE = re.compile( + r"^(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)" + r"(?:-(?P" + r"(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*" + r"))?$" +) + +DJANGO_PACKAGE_NAME = "nside-wefa" +BFF_PACKAGE_NAME = "backend-for-frontend" + + +@dataclass(frozen=True) +class VersionSource: + path: Path + reader: Callable[[Path], str] + + +def relpath(path: Path) -> str: + return path.relative_to(ROOT).as_posix() + + +def read_json_version(path: Path) -> str: + data = json.loads(path.read_text(encoding="utf-8")) + version = data.get("version") + if not isinstance(version, str): + raise ValueError("missing top-level version") + return version + + +def read_package_lock_version(path: Path) -> str: + data = json.loads(path.read_text(encoding="utf-8")) + root_version = data.get("version") + packages = data.get("packages", {}) + package_root = packages.get("", {}) if isinstance(packages, dict) else {} + package_version = package_root.get("version") + if not isinstance(root_version, str): + raise ValueError("missing top-level version") + if not isinstance(package_version, str): + raise ValueError('missing packages[""].version') + if root_version != package_version: + raise ValueError( + f"inconsistent package-lock versions: root={root_version}, packages[\"\"]={package_version}" + ) + return root_version + + +def read_pyproject_version(path: Path) -> str: + data = tomllib.loads(path.read_text(encoding="utf-8")) + project = data.get("project") + if not isinstance(project, dict): + raise ValueError("missing [project] section") + version = project.get("version") + if not isinstance(version, str): + raise ValueError("missing project.version") + return version + + +def read_uv_lock_package_version(path: Path, package_name: str) -> str: + data = tomllib.loads(path.read_text(encoding="utf-8")) + packages = data.get("package") + if not isinstance(packages, list): + raise ValueError("missing package entries") + + matched_versions = [ + pkg.get("version") + for pkg in packages + if isinstance(pkg, dict) and pkg.get("name") == package_name + ] + matched_versions = [value for value in matched_versions if isinstance(value, str)] + if len(matched_versions) != 1: + raise ValueError(f"expected exactly one package named {package_name!r}, found {len(matched_versions)}") + return matched_versions[0] + + +def read_django_init_version(path: Path) -> str: + text = path.read_text(encoding="utf-8") + match = re.search(r'(?m)^__version__\s*=\s*"([^"]+)"\s*$', text) + if not match: + raise ValueError("missing __version__ assignment") + return match.group(1) + + +VERSION_SOURCES: tuple[VersionSource, ...] = ( + VersionSource(ROOT / "vue/package.json", read_json_version), + VersionSource(ROOT / "vue/package-lock.json", read_package_lock_version), + VersionSource(ROOT / "django/pyproject.toml", read_pyproject_version), + VersionSource( + ROOT / "django/uv.lock", + lambda path: read_uv_lock_package_version(path, DJANGO_PACKAGE_NAME), + ), + VersionSource(ROOT / "django/nside_wefa/__init__.py", read_django_init_version), + VersionSource(ROOT / "bff/pyproject.toml", read_pyproject_version), + VersionSource( + ROOT / "bff/uv.lock", + lambda path: read_uv_lock_package_version(path, BFF_PACKAGE_NAME), + ), +) + + +def collect_versions() -> dict[str, str]: + versions: dict[str, str] = {} + for source in VERSION_SOURCES: + try: + version = source.reader(source.path) + except Exception as exc: # pragma: no cover - defensive branch + raise RuntimeError(f"failed to read version from {relpath(source.path)}: {exc}") from exc + versions[relpath(source.path)] = version + return versions + + +def unified_version(versions: dict[str, str]) -> str | None: + unique = sorted(set(versions.values())) + if len(unique) == 1: + return unique[0] + return None + + +def render_versions(versions: dict[str, str]) -> str: + lines = [f"- {path}: {version}" for path, version in versions.items()] + return "\n".join(lines) + + +def ensure_valid_semver(value: str, *, flag_name: str) -> None: + if not SEMVER_RE.fullmatch(value): + raise ValueError( + f"invalid {flag_name} {value!r}; expected strict SemVer (e.g. 1.2.3 or 1.2.3-rc.1)" + ) + + +def bump_semver(version: str, part: str) -> str: + match = SEMVER_RE.fullmatch(version) + if not match: + raise ValueError(f"cannot bump non-SemVer version {version!r}") + major = int(match.group("major")) + minor = int(match.group("minor")) + patch = int(match.group("patch")) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + if part == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"unknown bump part {part!r}") + + +def tracked_version_paths() -> list[str]: + return [relpath(source.path) for source in VERSION_SOURCES] + + +def git_dirty_version_files() -> list[str]: + command = ["git", "status", "--porcelain", "--", *tracked_version_paths()] + result = subprocess.run( + command, + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"failed to inspect git status: {result.stderr.strip()}") + + dirty: list[str] = [] + for line in result.stdout.splitlines(): + entry = line[3:] + if " -> " in entry: + entry = entry.split(" -> ", 1)[1] + dirty.append(entry) + return sorted(set(dirty)) + + +def ensure_dirty_preflight_allowed(allow_dirty: bool) -> None: + dirty_files = git_dirty_version_files() + if not dirty_files or allow_dirty: + return + formatted = "\n".join(f"- {path}" for path in dirty_files) + raise RuntimeError( + "refusing to run with modified version files:\n" + f"{formatted}\n" + "commit/revert those changes first, or use --allow-dirty-version-files" + ) + + +def run_command(command: list[str]) -> None: + print(f"+ {' '.join(shlex.quote(part) for part in command)}") + subprocess.run(command, cwd=ROOT, check=True) + + +def update_django_init_version(target_version: str) -> None: + path = ROOT / "django/nside_wefa/__init__.py" + text = path.read_text(encoding="utf-8") + pattern = re.compile(r'(?m)^__version__\s*=\s*"([^"]+)"\s*$') + match = pattern.search(text) + if not match: + raise RuntimeError(f"unable to locate __version__ in {relpath(path)}") + if match.group(1) == target_version: + return + updated, replacements = pattern.subn(f'__version__ = "{target_version}"', text, count=1) + if replacements != 1: + raise RuntimeError(f"unable to update __version__ in {relpath(path)}") + path.write_text(updated, encoding="utf-8") + + +def post_update_assertions(target_version: str) -> None: + versions = collect_versions() + merged = unified_version(versions) + if merged != target_version: + raise RuntimeError( + "post-update validation failed; files are not aligned:\n" + f"{render_versions(versions)}" + ) + + +def print_change_summary() -> None: + dirty_files = git_dirty_version_files() + if not dirty_files: + print("No version files changed.") + return + print("Changed version files:") + for path in dirty_files: + print(f"- {path}") + + +def preflight_for_mutation(command_name: str, allow_dirty: bool) -> dict[str, str]: + versions = collect_versions() + if command_name == "bump" and unified_version(versions) is None: + raise RuntimeError( + "cannot bump because version sources are inconsistent:\n" + f"{render_versions(versions)}\n" + "run `set ` first to realign all projects" + ) + ensure_dirty_preflight_allowed(allow_dirty) + return versions + + +def cmd_show(_: argparse.Namespace) -> int: + versions = collect_versions() + merged = unified_version(versions) + if merged is None: + print("Version mismatch detected:", file=sys.stderr) + print(render_versions(versions), file=sys.stderr) + return 1 + print(merged) + return 0 + + +def cmd_check(args: argparse.Namespace) -> int: + versions = collect_versions() + merged = unified_version(versions) + if merged is None: + print("Version mismatch detected:", file=sys.stderr) + print(render_versions(versions), file=sys.stderr) + return 1 + + if args.expect: + ensure_valid_semver(args.expect, flag_name="--expect") + if merged != args.expect: + print( + f"Version mismatch: expected {args.expect}, found {merged}", + file=sys.stderr, + ) + return 1 + print(f"OK: {merged}") + return 0 + + +def cmd_set(args: argparse.Namespace) -> int: + ensure_valid_semver(args.version, flag_name="version") + current_versions = preflight_for_mutation("set", args.allow_dirty_version_files) + current = unified_version(current_versions) + target = args.version + + if args.dry_run: + print(f"Dry run: would set monorepo version to {target}") + if current is not None: + print(f"Current unified version: {current}") + else: + print("Current versions are inconsistent:") + print(render_versions(current_versions)) + print("Files in scope:") + for path in tracked_version_paths(): + print(f"- {path}") + return 0 + + run_command(["uv", "version", "--project", "django", target, "--no-sync"]) + run_command(["uv", "version", "--project", "bff", target, "--no-sync"]) + run_command( + [ + "npm", + "--prefix", + "vue", + "version", + target, + "--no-git-tag-version", + "--allow-same-version", + ] + ) + update_django_init_version(target) + post_update_assertions(target) + print(f"Updated monorepo version to {target}") + print_change_summary() + return 0 + + +def cmd_bump(args: argparse.Namespace) -> int: + versions = preflight_for_mutation("bump", args.allow_dirty_version_files) + current = unified_version(versions) + assert current is not None # validated in preflight + + target = bump_semver(current, args.part) + if args.dry_run: + print(f"Dry run: would bump version from {current} to {target}") + print("Files in scope:") + for path in tracked_version_paths(): + print(f"- {path}") + return 0 + + run_command(["uv", "version", "--project", "django", target, "--no-sync"]) + run_command(["uv", "version", "--project", "bff", target, "--no-sync"]) + run_command( + [ + "npm", + "--prefix", + "vue", + "version", + target, + "--no-git-tag-version", + "--allow-same-version", + ] + ) + update_django_init_version(target) + post_update_assertions(target) + print(f"Bumped monorepo version from {current} to {target}") + print_change_summary() + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Manage the shared monorepo version across vue, django, and bff." + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + parser_show = subparsers.add_parser("show", help="Show the unified monorepo version.") + parser_show.set_defaults(func=cmd_show) + + parser_check = subparsers.add_parser("check", help="Check that all project versions are aligned.") + parser_check.add_argument( + "--expect", + help="Assert the unified version matches this SemVer value.", + ) + parser_check.set_defaults(func=cmd_check) + + parser_set = subparsers.add_parser("set", help="Set the unified monorepo version.") + parser_set.add_argument("version", help="Target version in strict SemVer format.") + parser_set.add_argument("--dry-run", action="store_true", help="Preview changes without writing files.") + parser_set.add_argument( + "--allow-dirty-version-files", + action="store_true", + help="Allow running when tracked version files are already modified.", + ) + parser_set.set_defaults(func=cmd_set) + + parser_bump = subparsers.add_parser("bump", help="Bump the unified monorepo version.") + parser_bump.add_argument("part", choices=["major", "minor", "patch"], help="Version segment to bump.") + parser_bump.add_argument("--dry-run", action="store_true", help="Preview changes without writing files.") + parser_bump.add_argument( + "--allow-dirty-version-files", + action="store_true", + help="Allow running when tracked version files are already modified.", + ) + parser_bump.set_defaults(func=cmd_bump) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except subprocess.CalledProcessError as exc: + command = " ".join(shlex.quote(part) for part in exc.cmd) + print(f"error: command failed ({exc.returncode}): {command}", file=sys.stderr) + return exc.returncode or 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vue/CONTRIBUTE.md b/vue/CONTRIBUTE.md index 69d80ffc..63d7faa8 100644 --- a/vue/CONTRIBUTE.md +++ b/vue/CONTRIBUTE.md @@ -114,7 +114,11 @@ Document deviations from these gates in the pull request summary and explain the Our first public release will be automated through GitHub Actions. For now follow the manual flow when testing locally: ```bash -npm version +cd .. +python3 scripts/wefa_version.py bump +# or: +python3 scripts/wefa_version.py set +cd vue npm publish --access public ``` diff --git a/vue/README.md b/vue/README.md index ec3aec6c..6ff8ab7f 100644 --- a/vue/README.md +++ b/vue/README.md @@ -184,9 +184,13 @@ We welcome pull requests! Start with [`CONTRIBUTE`](CONTRIBUTE.md) for the full ## Release process -Versioning currently follows SemVer. To test a release locally: +Versioning for this repository is managed from the monorepo root so `vue`, `django`, and `bff` stay aligned: ```bash -npm version +cd .. +python3 scripts/wefa_version.py bump +# or: +python3 scripts/wefa_version.py set +cd vue npm publish --access public ``` From 972ff4ee9e230d1fe1834e690ace00438e6cb710 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 4 Mar 2026 11:43:07 +0100 Subject: [PATCH 04/24] Add BFF architecture reference links to README - Include links to OAuth BFF architecture documentation for better conceptual understanding. - Update README to guide users toward detailed external resources. --- bff/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bff/README.md b/bff/README.md index fa81d17b..31525fcf 100644 --- a/bff/README.md +++ b/bff/README.md @@ -2,6 +2,10 @@ This repo provides a Flask BFF that handles OAuth login/logout/session checks and proxies REST calls to a backend. +For more information on the BFF architecture, see: +- https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-backend-for-frontend-bff +- https://auth0.com/blog/the-backend-for-frontend-pattern-bff/ + **Quick Start** 1. Create `.env` from `.env.example` in the repo root (do not commit `.env`). 2. `uv sync --dev` From b7e33c462dd9e26a6160e588f8c28ed269b8b6f8 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 4 Mar 2026 11:45:00 +0100 Subject: [PATCH 05/24] Add workflow to build and publish BFF Docker image - Introduce `publish-bff-image` job in GitHub Actions workflow to automate building, tagging, and publishing of BFF Docker images on releases. - Ensure compatibility with tag-based versioning and handle pre-releases appropriately. --- .github/workflows/release.yml | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f56ae739..5f2d5d81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,42 @@ permissions: contents: read jobs: + publish-bff-image: + name: Build, tag and publish BFF image + if: ${{ !github.event.release.draft }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.OCI_REGISTRY_HOST }} + username: ${{ secrets.OCI_REGISTRY_USERNAME }} + password: ${{ secrets.OCI_REGISTRY_PASSWORD }} + + - name: Compute image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.OCI_REGISTRY_HOST }}/${{ secrets.OCI_BFF_IMAGE_REPOSITORY }} + tags: | + type=raw,value=${{ github.event.release.tag_name }} + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: ./bff + file: ./bff/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + publish-npm: name: Publish Vue package to npm runs-on: ubuntu-latest From 8d0c95238fdd32201b80eba5d5d618c338156cbb Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 4 Mar 2026 11:49:31 +0100 Subject: [PATCH 06/24] Update Python and dependency requirements; remove Redis - Require Python 3.12.x in `README.md` per `pyproject.toml`. - Remove `redis` dependency from `pyproject.toml` and `uv.lock` as it is no longer used. --- bff/README.md | 2 +- bff/pyproject.toml | 1 - bff/uv.lock | 11 ----------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/bff/README.md b/bff/README.md index 31525fcf..2fa08ac4 100644 --- a/bff/README.md +++ b/bff/README.md @@ -12,7 +12,7 @@ For more information on the BFF architecture, see: 3. `FLASK_RUN_PORT=5022 uv run flask --app bff.py run` **Prerequisites** -- Python 3.x +- Python 3.12.x (per `pyproject.toml` requirement: Python >=3.12,<3.13) - `uv` **Environment File** diff --git a/bff/pyproject.toml b/bff/pyproject.toml index 6a79326a..75c169e7 100644 --- a/bff/pyproject.toml +++ b/bff/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "flask-smorest>=0.46.2", "pyyaml~=6.0", "python-dotenv~=1.2.1", - "redis", "requests", ] diff --git a/bff/uv.lock b/bff/uv.lock index 378af92c..89a37156 100644 --- a/bff/uv.lock +++ b/bff/uv.lock @@ -43,7 +43,6 @@ dependencies = [ { name = "flask-smorest" }, { name = "python-dotenv" }, { name = "pyyaml" }, - { name = "redis" }, { name = "requests" }, ] @@ -61,7 +60,6 @@ requires-dist = [ { name = "flask-smorest", specifier = ">=0.46.2" }, { name = "python-dotenv", specifier = "~=1.2.1" }, { name = "pyyaml", specifier = "~=6.0" }, - { name = "redis" }, { name = "requests" }, ] @@ -425,15 +423,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] -[[package]] -name = "redis" -version = "7.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, -] - [[package]] name = "requests" version = "2.32.5" From 638c8d9764fe0dc3bc56a89fa6a6aa1c45a65576 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Thu, 5 Mar 2026 12:02:42 +0100 Subject: [PATCH 07/24] Add environment validation and test coverage for `load_settings_from_env` - Introduce `SettingsValidationError` to handle missing or blank required environment variables in `bff_app/settings.py`. - Update `bff.py` to fail fast on invalid configuration, improving runtime error detection during startup. - Adjust default OpenAPI generation settings in `generate.py` for better alignment with updated environment requirements. --- bff/.env.example | 2 + bff/README.md | 12 +++- bff/bff.py | 3 +- bff/bff_app/openapi/generate.py | 2 +- bff/bff_app/settings.py | 102 ++++++++++++++++++++++++-------- bff/tests/test_settings.py | 70 ++++++++++++++++++++++ 6 files changed, 160 insertions(+), 31 deletions(-) create mode 100644 bff/tests/test_settings.py diff --git a/bff/.env.example b/bff/.env.example index 7836be5f..859b7847 100644 --- a/bff/.env.example +++ b/bff/.env.example @@ -27,6 +27,8 @@ FRONTEND_REDIRECT="http://localhost:5178" # Session cookie SESSION_COOKIE_NAME="Fake__Host-session" # Must have __Host- prefix in a non-local env +SESSION_COOKIE_PATH="/" +SESSION_COOKIE_HTTPONLY="True" SESSION_COOKIE_SECURE="True" SESSION_COOKIE_SAMESITE="Strict" diff --git a/bff/README.md b/bff/README.md index 2fa08ac4..ce41d767 100644 --- a/bff/README.md +++ b/bff/README.md @@ -18,17 +18,23 @@ For more information on the BFF architecture, see: **Environment File** - Location: repo root `.env` (same directory as `bff.py`) - Template: `.env.example` -- Required values from your OAuth server: +- Required values (validated at startup): +- `FLASK_SECRET_KEY` +- `BACKEND_ENDPOINT` +- `CORS_ALLOWED_ORIGIN` - `OAUTH_CLIENT_ID` - `OAUTH_CLIENT_SECRET` +- `OAUTH_OIDC_SCOPE` - `OAUTH_ENDPOINT_AUTHORIZATION` - `OAUTH_ENDPOINT_TOKEN` - `OAUTH_ENDPOINT_USERINFO` -- Required values from your frontend: +- `OAUTH_ENDPOINT_LOGOUT` - `FRONTEND_REDIRECT` - `OAUTH_LOGIN_REDIRECT_URI` - Session cookie configuration: - `SESSION_COOKIE_NAME` +- `SESSION_COOKIE_PATH` +- `SESSION_COOKIE_HTTPONLY` - `SESSION_COOKIE_SECURE` - `SESSION_COOKIE_SAMESITE` @@ -71,4 +77,4 @@ docker-compose up --build **Notes / Common Pitfalls** - If you run `bff.py` directly (not `flask run`), Flask will default to port 5000 unless you explicitly set the port in code. -- `dotenv` loads the `.env` file at process start. If the `.env` is missing or the working directory is wrong, defaults will be used. +- `dotenv` loads the `.env` file at process start. Startup now fails fast with a validation error if required variables are missing or blank. diff --git a/bff/bff.py b/bff/bff.py index 54b88b36..01431eaf 100644 --- a/bff/bff.py +++ b/bff/bff.py @@ -11,7 +11,8 @@ dotenv.load_dotenv() -app = create_app(settings=load_settings_from_env()) +settings = load_settings_from_env() +app = create_app(settings=settings) if __name__ == "__main__": diff --git a/bff/bff_app/openapi/generate.py b/bff/bff_app/openapi/generate.py index e38877bb..b5cf2669 100644 --- a/bff/bff_app/openapi/generate.py +++ b/bff/bff_app/openapi/generate.py @@ -20,7 +20,7 @@ def _spec_settings() -> BffSettings: session_cookie_httponly=True, session_cookie_secure=True, session_cookie_samesite="Strict", - cors_allowed_origin=None, + cors_allowed_origin="http://frontend.example", backend_endpoint="http://backend.example/api", oauth_client_id="client-id", oauth_client_secret="client-secret", diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py index b23db43b..462b6312 100644 --- a/bff/bff_app/settings.py +++ b/bff/bff_app/settings.py @@ -4,6 +4,11 @@ import os from dataclasses import dataclass +from typing import Mapping + + +class SettingsValidationError(ValueError): + """Raised when required BFF environment configuration is missing.""" def _env_bool(name: str, default: bool) -> bool: @@ -42,23 +47,65 @@ class BffSettings: :ivar oauth_login_redirect_uri: Redirect URI handled by the BFF callback. :ivar frontend_redirect: Frontend URL used after login callback. """ - flask_secret_key: str | None - session_cookie_name: str | None + flask_secret_key: str + session_cookie_name: str session_cookie_path: str session_cookie_httponly: bool session_cookie_secure: bool session_cookie_samesite: str - cors_allowed_origin: str | None - backend_endpoint: str | None - oauth_client_id: str | None - oauth_client_secret: str | None - oauth_oidc_scope: str | None - oauth_endpoint_authorization: str | None - oauth_endpoint_token: str | None - oauth_endpoint_userinfo: str | None - oauth_endpoint_logout: str | None - oauth_login_redirect_uri: str | None - frontend_redirect: str | None + cors_allowed_origin: str + backend_endpoint: str + oauth_client_id: str + oauth_client_secret: str + oauth_oidc_scope: str + oauth_endpoint_authorization: str + oauth_endpoint_token: str + oauth_endpoint_userinfo: str + oauth_endpoint_logout: str + oauth_login_redirect_uri: str + frontend_redirect: str + + +REQUIRED_ENV_VARS: tuple[str, ...] = ( + "FLASK_SECRET_KEY", + "SESSION_COOKIE_NAME", + "SESSION_COOKIE_PATH", + "SESSION_COOKIE_HTTPONLY", + "SESSION_COOKIE_SECURE", + "SESSION_COOKIE_SAMESITE", + "CORS_ALLOWED_ORIGIN", + "BACKEND_ENDPOINT", + "OAUTH_CLIENT_ID", + "OAUTH_CLIENT_SECRET", + "OAUTH_OIDC_SCOPE", + "OAUTH_ENDPOINT_AUTHORIZATION", + "OAUTH_ENDPOINT_TOKEN", + "OAUTH_ENDPOINT_USERINFO", + "OAUTH_ENDPOINT_LOGOUT", + "OAUTH_LOGIN_REDIRECT_URI", + "FRONTEND_REDIRECT", +) + + +def validate_required_env(raw_env: Mapping[str, str | None]) -> None: + """Validate that all required env vars exist and are non-empty. + + :param raw_env: + Environment values keyed by variable name. + :raises SettingsValidationError: + If one or more required variables are missing/blank. + """ + missing = sorted( + name + for name in REQUIRED_ENV_VARS + if raw_env.get(name) is None or not str(raw_env.get(name)).strip() + ) + if missing: + missing_csv = ", ".join(missing) + raise SettingsValidationError( + "Missing required BFF environment variables: " + f"{missing_csv}. Configure them before starting the service." + ) def load_settings_from_env() -> BffSettings: @@ -67,22 +114,25 @@ def load_settings_from_env() -> BffSettings: :returns: Environment-derived application settings. :rtype: BffSettings """ + raw_env = {name: os.getenv(name) for name in REQUIRED_ENV_VARS} + validate_required_env(raw_env) + return BffSettings( - flask_secret_key=os.getenv("FLASK_SECRET_KEY"), - session_cookie_name=os.getenv("SESSION_COOKIE_NAME"), + flask_secret_key=raw_env["FLASK_SECRET_KEY"], + session_cookie_name=raw_env["SESSION_COOKIE_NAME"], session_cookie_path=os.getenv("SESSION_COOKIE_PATH", "/"), session_cookie_httponly=_env_bool("SESSION_COOKIE_HTTPONLY", True), session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE", True), session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "Strict"), - cors_allowed_origin=os.getenv("CORS_ALLOWED_ORIGIN"), - backend_endpoint=os.getenv("BACKEND_ENDPOINT"), - oauth_client_id=os.getenv("OAUTH_CLIENT_ID"), - oauth_client_secret=os.getenv("OAUTH_CLIENT_SECRET"), - oauth_oidc_scope=os.getenv("OAUTH_OIDC_SCOPE"), - oauth_endpoint_authorization=os.getenv("OAUTH_ENDPOINT_AUTHORIZATION"), - oauth_endpoint_token=os.getenv("OAUTH_ENDPOINT_TOKEN"), - oauth_endpoint_userinfo=os.getenv("OAUTH_ENDPOINT_USERINFO"), - oauth_endpoint_logout=os.getenv("OAUTH_ENDPOINT_LOGOUT"), - oauth_login_redirect_uri=os.getenv("OAUTH_LOGIN_REDIRECT_URI"), - frontend_redirect=os.getenv("FRONTEND_REDIRECT"), + cors_allowed_origin=raw_env["CORS_ALLOWED_ORIGIN"], + backend_endpoint=raw_env["BACKEND_ENDPOINT"], + oauth_client_id=raw_env["OAUTH_CLIENT_ID"], + oauth_client_secret=raw_env["OAUTH_CLIENT_SECRET"], + oauth_oidc_scope=raw_env["OAUTH_OIDC_SCOPE"], + oauth_endpoint_authorization=raw_env["OAUTH_ENDPOINT_AUTHORIZATION"], + oauth_endpoint_token=raw_env["OAUTH_ENDPOINT_TOKEN"], + oauth_endpoint_userinfo=raw_env["OAUTH_ENDPOINT_USERINFO"], + oauth_endpoint_logout=raw_env["OAUTH_ENDPOINT_LOGOUT"], + oauth_login_redirect_uri=raw_env["OAUTH_LOGIN_REDIRECT_URI"], + frontend_redirect=raw_env["FRONTEND_REDIRECT"], ) diff --git a/bff/tests/test_settings.py b/bff/tests/test_settings.py new file mode 100644 index 00000000..70d36c49 --- /dev/null +++ b/bff/tests/test_settings.py @@ -0,0 +1,70 @@ +import pytest + +from bff_app.settings import SettingsValidationError, load_settings_from_env + + +def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FLASK_SECRET_KEY", "test-secret") + monkeypatch.setenv("SESSION_COOKIE_NAME", "test-session") + monkeypatch.setenv("SESSION_COOKIE_PATH", "/") + monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") + monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") + monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") + monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://frontend.test") + monkeypatch.setenv("BACKEND_ENDPOINT", "http://backend.test/api") + monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id") + monkeypatch.setenv("OAUTH_CLIENT_SECRET", "client-secret") + monkeypatch.setenv("OAUTH_OIDC_SCOPE", "openid profile") + monkeypatch.setenv("OAUTH_ENDPOINT_AUTHORIZATION", "http://auth.test/authorize") + monkeypatch.setenv("OAUTH_ENDPOINT_TOKEN", "http://auth.test/token") + monkeypatch.setenv("OAUTH_ENDPOINT_USERINFO", "http://auth.test/userinfo") + monkeypatch.setenv("OAUTH_ENDPOINT_LOGOUT", "http://auth.test/logout") + monkeypatch.setenv( + "OAUTH_LOGIN_REDIRECT_URI", + "http://app.test/proxy/api/auth/callback", + ) + monkeypatch.setenv("FRONTEND_REDIRECT", "http://frontend.test") + + +def test_load_settings_from_env_requires_all_vars(monkeypatch: pytest.MonkeyPatch): + _set_required_env(monkeypatch) + monkeypatch.delenv("OAUTH_ENDPOINT_TOKEN", raising=False) + + with pytest.raises(SettingsValidationError, match="OAUTH_ENDPOINT_TOKEN"): + load_settings_from_env() + + +def test_load_settings_from_env_rejects_blank_values( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + monkeypatch.setenv("FRONTEND_REDIRECT", " ") + + with pytest.raises(SettingsValidationError, match="FRONTEND_REDIRECT"): + load_settings_from_env() + + +def test_load_settings_from_env_returns_validated_settings( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + + settings = load_settings_from_env() + + assert settings.flask_secret_key == "test-secret" + assert settings.session_cookie_name == "test-session" + assert settings.session_cookie_path == "/" + assert settings.session_cookie_httponly is True + assert settings.session_cookie_secure is False + assert settings.session_cookie_samesite == "Lax" + assert settings.cors_allowed_origin == "http://frontend.test" + assert settings.backend_endpoint == "http://backend.test/api" + assert settings.oauth_client_id == "client-id" + assert settings.oauth_client_secret == "client-secret" + assert settings.oauth_oidc_scope == "openid profile" + assert settings.oauth_endpoint_authorization == "http://auth.test/authorize" + assert settings.oauth_endpoint_token == "http://auth.test/token" + assert settings.oauth_endpoint_userinfo == "http://auth.test/userinfo" + assert settings.oauth_endpoint_logout == "http://auth.test/logout" + assert settings.oauth_login_redirect_uri == "http://app.test/proxy/api/auth/callback" + assert settings.frontend_redirect == "http://frontend.test" From 2b1ce98261642e7175a44259aa6b873188db9426 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Thu, 5 Mar 2026 12:03:09 +0100 Subject: [PATCH 08/24] Update README: agnosticize BFF title --- bff/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bff/README.md b/bff/README.md index ce41d767..bb713182 100644 --- a/bff/README.md +++ b/bff/README.md @@ -1,4 +1,4 @@ -# MMS Backend-for-Frontend (BFF) +# Backend-for-Frontend (BFF) This repo provides a Flask BFF that handles OAuth login/logout/session checks and proxies REST calls to a backend. From 45d35c9bdfa0b5850e42a635f622c211e2262359 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Thu, 5 Mar 2026 14:09:18 +0100 Subject: [PATCH 09/24] Update README to use shorthand flag `-h` in versioning script example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cfd964d..b22e1983 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ This monorepo uses one shared version across `vue/`, `django/`, and `bff/`. Use the root orchestrator script for any version change: ```bash -python3 scripts/wefa_version.py help +python3 scripts/wefa_version.py -h python3 scripts/wefa_version.py show python3 scripts/wefa_version.py check --expect python3 scripts/wefa_version.py bump patch From 292b88f781346a03b3d896bcc5c5bc4bbe41380e Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:20:42 +0100 Subject: [PATCH 10/24] Make container port explicit in Dockerfile; set default to 5000 --- bff/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bff/Dockerfile b/bff/Dockerfile index 3831d924..d59dc927 100644 --- a/bff/Dockerfile +++ b/bff/Dockerfile @@ -21,9 +21,9 @@ RUN uv sync --frozen --no-dev --no-install-project # Do this after installing the dependencies to avoid breaking Docker cache COPY . /app -# Make port 5000 available to the world outside this container -ARG PORT -EXPOSE $PORT +# Make the exposed container port explicit; runtime mapping can still override host port +ARG PORT=5000 +EXPOSE ${PORT} # Run app.py when the container launches CMD ["sh", "-c", "uv run flask --app bff.py run --host 0.0.0.0 --port ${PORT:-5000}"] From 9bf8c448d539dc960821d4bf2b4e8207a94b7841 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:21:49 +0100 Subject: [PATCH 11/24] Update OpenAPI spec paths in README for consistency with folder structure --- bff/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bff/README.md b/bff/README.md index bb713182..9fc4eb09 100644 --- a/bff/README.md +++ b/bff/README.md @@ -55,11 +55,11 @@ FLASK_RUN_PORT=5022 uv run flask --app bff.py run **Generate OpenAPI Spec** 1. Generate or refresh the spec file. ```bash -uv run python -m bff_app.openapi.generate --output openapi.yaml +uv run python -m bff_app.openapi.generate --output bff_app/openapi/openapi.yaml ``` 2. Validate that the committed spec is up to date. ```bash -uv run python -m bff_app.openapi.generate --check --output openapi.yaml +uv run python -m bff_app.openapi.generate --check --output bff_app/openapi/openapi.yaml ``` **Run with Docker** From 297a659de60265c9e0c762efa33337cd5ae3b6ac Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:27:22 +0100 Subject: [PATCH 12/24] Filter hop-by-hop and sensitive headers in proxy requests --- bff/bff_app/routes/proxy.py | 39 ++++++++++++++++++++++++++++++++- bff/tests/test_proxy_request.py | 19 +++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py index d6650dd7..c8f26114 100644 --- a/bff/bff_app/routes/proxy.py +++ b/bff/bff_app/routes/proxy.py @@ -14,6 +14,42 @@ description="Backend passthrough proxy endpoints.", ) +HOP_BY_HOP_REQUEST_HEADERS = { + "connection", + "keep-alive", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} +SENSITIVE_REQUEST_HEADERS = { + "cookie", +} + + +def _build_upstream_headers() -> dict[str, str]: + """Filter incoming request headers before forwarding upstream.""" + connection_header = request.headers.get("Connection", "") + connection_scoped_headers = { + header_name.strip().lower() + for header_name in connection_header.split(",") + if header_name.strip() + } + excluded_headers = ( + HOP_BY_HOP_REQUEST_HEADERS + | SENSITIVE_REQUEST_HEADERS + | {"host"} + | connection_scoped_headers + ) + return { + key: value + for key, value in request.headers + if key.lower() not in excluded_headers + } + @proxy_bp.route( "/proxy/api/request/", @@ -59,6 +95,7 @@ def proxy_request(rest_of_url: str): Behavior: - Handles CORS preflight by returning ``204`` on ``OPTIONS``. + - Removes sensitive/hop-by-hop request headers before forwarding upstream. - Injects bearer access token from session when available. - Retries once after token refresh when upstream returns ``401`` with ``invalid_token``. @@ -67,7 +104,7 @@ def proxy_request(rest_of_url: str): print("-> /proxy/api/request/" + rest_of_url) settings = get_settings() - headers = {k: v for k, v in request.headers if k.lower() != "host"} + headers = _build_upstream_headers() payload = request.get_data() if "token" in session and "access_token" in session["token"]: diff --git a/bff/tests/test_proxy_request.py b/bff/tests/test_proxy_request.py index e0d53687..096305bf 100644 --- a/bff/tests/test_proxy_request.py +++ b/bff/tests/test_proxy_request.py @@ -22,13 +22,30 @@ def test_proxy_request_forwards_to_backend(client, monkeypatch): with client.session_transaction() as sess: sess["token"] = {"access_token": "access-token"} - res = client.post("/proxy/api/request/widgets?limit=5", data=b'{"x":1}') + res = client.post( + "/proxy/api/request/widgets?limit=5", + data=b'{"x":1}', + headers={ + "Cookie": "session=browser-cookie", + "Connection": "keep-alive, x-remove-me", + "X-Remove-Me": "secret", + "X-Keep": "ok", + }, + ) assert res.status_code == 200 assert res.data == b'{"ok":true}' # Ensure the upstream call targeted the configured backend base URL. assert mock_request.call_args.kwargs["url"] == "http://backend.test/api/widgets" assert dict(mock_request.call_args.kwargs["params"]) == {"limit": "5"} + forwarded_headers = { + key.lower(): value for key, value in mock_request.call_args.kwargs["headers"].items() + } + assert forwarded_headers["x-keep"] == "ok" + assert "cookie" not in forwarded_headers + assert "connection" not in forwarded_headers + assert "keep-alive" not in forwarded_headers + assert "x-remove-me" not in forwarded_headers # Flask should drop hop-by-hop headers from the upstream response. assert "transfer-encoding" not in res.headers # CORS header should be set by flask_cors, not forwarded from upstream. From 023f28175b6c4d42a3ca77249abc9ab388ce9d6c Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:31:01 +0100 Subject: [PATCH 13/24] Refactor OAuth callback handling to improve session management and error handling. --- bff/bff_app/routes/auth.py | 69 +++++++++++++++++++-------------- bff/tests/test_auth_callback.py | 50 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 29 deletions(-) diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index a98f59ba..fada058e 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -97,46 +97,57 @@ def login_cb(): :returns: Redirect response to the configured frontend URL. :rtype: flask.Response - :raises ValueError: - If required callback state information is missing. """ print("-> /proxy/api/auth/callback") settings = get_settings() - try: - if "state" not in session: - raise ValueError("State not found in cookie, cookie might be missing") - if "state" not in request.args: - raise ValueError("State not found in request arguments, incorrect request") - if request.args["state"] != session["state"]: - print("State mismatch") - return redirect(settings.frontend_redirect, code=302) - - print("State match !") - print("Performing Code Exchange") - - client = OAuth2Session( - settings.oauth_client_id, - settings.oauth_client_secret, - scope=settings.oauth_oidc_scope, - code_challenge_method="S256", - redirect_uri=settings.oauth_login_redirect_uri, - ) + request_state = request.args.get("state") + session_state = session.get("state") + authorization_code = request.args.get("code") + code_verifier = session.get("cv") + + if not session_state or not request_state: + print("Missing OAuth state during callback") + session.clear() + return redirect(settings.frontend_redirect, code=302) + if request_state != session_state: + print("State mismatch") + session.clear() + return redirect(settings.frontend_redirect, code=302) + if not authorization_code or not code_verifier: + print("Missing authorization code or PKCE verifier during callback") + session.clear() + return redirect(settings.frontend_redirect, code=302) + + print("State match !") + print("Performing Code Exchange") + + client = OAuth2Session( + settings.oauth_client_id, + settings.oauth_client_secret, + scope=settings.oauth_oidc_scope, + code_challenge_method="S256", + redirect_uri=settings.oauth_login_redirect_uri, + ) - authorization_response = request.url - print("Authorization Code : ", authorization_response) + authorization_response = request.url + print("Authorization Code : ", authorization_response) + try: token = client.fetch_token( settings.oauth_endpoint_token, authorization_response=authorization_response, - code_verifier=session["cv"], + code_verifier=code_verifier, ) - session["token"] = token - - return redirect(settings.frontend_redirect, code=302) except Exception as exc: - print(exc) - raise exc + print(f"Token exchange failed: {exc}") + session.clear() + return redirect(settings.frontend_redirect, code=302) + + session["token"] = token + session.pop("cv", None) + session.pop("state", None) + return redirect(settings.frontend_redirect, code=302) @auth_bp.route("/logout", methods=["GET"]) diff --git a/bff/tests/test_auth_callback.py b/bff/tests/test_auth_callback.py index 47f8888b..e2e75f32 100644 --- a/bff/tests/test_auth_callback.py +++ b/bff/tests/test_auth_callback.py @@ -32,11 +32,14 @@ def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): # Token should be saved to the session after the code exchange. with client.session_transaction() as sess: assert sess["token"]["access_token"] == "tok" + assert "state" not in sess + assert "cv" not in sess def test_login_callback_state_mismatch_redirects(client): # State mismatch short-circuits without token exchange. with client.session_transaction() as sess: + sess["token"] = {"access_token": "old"} sess["state"] = "state-123" sess["cv"] = "cv-hex" @@ -44,3 +47,50 @@ def test_login_callback_state_mismatch_redirects(client): assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + with client.session_transaction() as sess: + assert len(sess.keys()) == 0 + + +def test_login_callback_missing_state_redirects_and_clears_session(client): + with client.session_transaction() as sess: + sess["token"] = {"access_token": "old"} + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?code=abc") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test" + with client.session_transaction() as sess: + assert len(sess.keys()) == 0 + + +def test_login_callback_missing_code_redirects_and_clears_session(client): + with client.session_transaction() as sess: + sess["token"] = {"access_token": "old"} + sess["state"] = "state-123" + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?state=state-123") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test" + with client.session_transaction() as sess: + assert len(sess.keys()) == 0 + + +def test_login_callback_token_exchange_error_redirects_without_500(client, monkeypatch): + fake_oauth = _fake_oauth_session() + fake_oauth.fetch_token.side_effect = RuntimeError("boom") + monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "old"} + sess["state"] = "state-123" + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?state=state-123&code=abc") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test" + with client.session_transaction() as sess: + assert len(sess.keys()) == 0 From 183d48a05ae03bd726088748b5c21956c6b1e721 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:33:54 +0100 Subject: [PATCH 14/24] Add support for configurable backend proxy timeouts --- bff/.env.example | 4 ++++ bff/README.md | 3 +++ bff/bff_app/routes/proxy.py | 4 ++++ bff/bff_app/settings.py | 42 +++++++++++++++++++++++++++++++++ bff/tests/test_proxy_request.py | 5 ++++ bff/tests/test_settings.py | 28 ++++++++++++++++++++++ 6 files changed, 86 insertions(+) diff --git a/bff/.env.example b/bff/.env.example index 859b7847..0cadf18c 100644 --- a/bff/.env.example +++ b/bff/.env.example @@ -34,3 +34,7 @@ SESSION_COOKIE_SAMESITE="Strict" # Server we're proxying BACKEND_ENDPOINT="http://host.docker.internal:8080/api" + +# Optional backend proxy timeouts in seconds +BACKEND_CONNECT_TIMEOUT_SECONDS="3" +BACKEND_READ_TIMEOUT_SECONDS="30" diff --git a/bff/README.md b/bff/README.md index 9fc4eb09..77450260 100644 --- a/bff/README.md +++ b/bff/README.md @@ -37,6 +37,9 @@ For more information on the BFF architecture, see: - `SESSION_COOKIE_HTTPONLY` - `SESSION_COOKIE_SECURE` - `SESSION_COOKIE_SAMESITE` +- Optional values: +- `BACKEND_CONNECT_TIMEOUT_SECONDS` (default: `3`) +- `BACKEND_READ_TIMEOUT_SECONDS` (default: `30`) **Run Locally (Flask / PyCharm)** 1. Ensure `.env` exists in the repo root. diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py index c8f26114..59946b87 100644 --- a/bff/bff_app/routes/proxy.py +++ b/bff/bff_app/routes/proxy.py @@ -121,6 +121,10 @@ def forward_request(): headers=headers, params=request.args, data=payload, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), allow_redirects=False, ) diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py index 462b6312..118ac0b9 100644 --- a/bff/bff_app/settings.py +++ b/bff/bff_app/settings.py @@ -25,6 +25,34 @@ def _env_bool(name: str, default: bool) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _env_positive_float(name: str, default: float) -> float: + """Parse a positive float environment variable. + + :param name: Environment variable name. + :param default: Value returned when the environment variable is absent. + :returns: Parsed positive float. + :rtype: float + :raises SettingsValidationError: + If the variable is present but not a strictly positive number. + """ + value = os.getenv(name) + if value is None: + return default + + try: + parsed = float(value) + except ValueError as exc: + raise SettingsValidationError( + f"{name} must be a positive number. Received: {value!r}" + ) from exc + + if parsed <= 0: + raise SettingsValidationError( + f"{name} must be greater than 0. Received: {value!r}" + ) + return parsed + + @dataclass(frozen=True) class BffSettings: """Immutable runtime settings used by endpoint handlers. @@ -46,6 +74,10 @@ class BffSettings: :ivar oauth_endpoint_logout: OAuth logout endpoint URL. :ivar oauth_login_redirect_uri: Redirect URI handled by the BFF callback. :ivar frontend_redirect: Frontend URL used after login callback. + :ivar backend_connect_timeout_seconds: + Connect timeout in seconds for backend proxy requests. + :ivar backend_read_timeout_seconds: + Read timeout in seconds for backend proxy requests. """ flask_secret_key: str session_cookie_name: str @@ -64,6 +96,8 @@ class BffSettings: oauth_endpoint_logout: str oauth_login_redirect_uri: str frontend_redirect: str + backend_connect_timeout_seconds: float = 3.0 + backend_read_timeout_seconds: float = 30.0 REQUIRED_ENV_VARS: tuple[str, ...] = ( @@ -135,4 +169,12 @@ def load_settings_from_env() -> BffSettings: oauth_endpoint_logout=raw_env["OAUTH_ENDPOINT_LOGOUT"], oauth_login_redirect_uri=raw_env["OAUTH_LOGIN_REDIRECT_URI"], frontend_redirect=raw_env["FRONTEND_REDIRECT"], + backend_connect_timeout_seconds=_env_positive_float( + "BACKEND_CONNECT_TIMEOUT_SECONDS", + 3.0, + ), + backend_read_timeout_seconds=_env_positive_float( + "BACKEND_READ_TIMEOUT_SECONDS", + 30.0, + ), ) diff --git a/bff/tests/test_proxy_request.py b/bff/tests/test_proxy_request.py index 096305bf..d3e8b9b2 100644 --- a/bff/tests/test_proxy_request.py +++ b/bff/tests/test_proxy_request.py @@ -38,6 +38,7 @@ def test_proxy_request_forwards_to_backend(client, monkeypatch): # Ensure the upstream call targeted the configured backend base URL. assert mock_request.call_args.kwargs["url"] == "http://backend.test/api/widgets" assert dict(mock_request.call_args.kwargs["params"]) == {"limit": "5"} + assert mock_request.call_args.kwargs["timeout"] == (3.0, 30.0) forwarded_headers = { key.lower(): value for key, value in mock_request.call_args.kwargs["headers"].items() } @@ -94,3 +95,7 @@ def test_proxy_request_retries_on_invalid_token(client, monkeypatch): assert res.status_code == 200 assert res.data == b'{"ok":true}' assert mock_request.call_count == 2 + first_call_timeout = mock_request.call_args_list[0].kwargs["timeout"] + second_call_timeout = mock_request.call_args_list[1].kwargs["timeout"] + assert first_call_timeout == (3.0, 30.0) + assert second_call_timeout == (3.0, 30.0) diff --git a/bff/tests/test_settings.py b/bff/tests/test_settings.py index 70d36c49..ea1f31ee 100644 --- a/bff/tests/test_settings.py +++ b/bff/tests/test_settings.py @@ -68,3 +68,31 @@ def test_load_settings_from_env_returns_validated_settings( assert settings.oauth_endpoint_logout == "http://auth.test/logout" assert settings.oauth_login_redirect_uri == "http://app.test/proxy/api/auth/callback" assert settings.frontend_redirect == "http://frontend.test" + assert settings.backend_connect_timeout_seconds == 3.0 + assert settings.backend_read_timeout_seconds == 30.0 + + +def test_load_settings_from_env_accepts_custom_backend_timeouts( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + monkeypatch.setenv("BACKEND_CONNECT_TIMEOUT_SECONDS", "1.5") + monkeypatch.setenv("BACKEND_READ_TIMEOUT_SECONDS", "12") + + settings = load_settings_from_env() + + assert settings.backend_connect_timeout_seconds == 1.5 + assert settings.backend_read_timeout_seconds == 12.0 + + +def test_load_settings_from_env_rejects_invalid_backend_timeouts( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + monkeypatch.setenv("BACKEND_CONNECT_TIMEOUT_SECONDS", "0") + + with pytest.raises( + SettingsValidationError, + match="BACKEND_CONNECT_TIMEOUT_SECONDS must be greater than 0", + ): + load_settings_from_env() From 9f906cebe27483ab66a8784ca5af5b2fd90c13cb Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:37:56 +0100 Subject: [PATCH 15/24] Improve userinfo proxy to handle errors, timeouts, and missing tokens - Add error handling for upstream timeouts and invalid responses in `/proxy/api/auth/userinfo` route. - Clear session on 401/403 responses from upstream. - Enforce configurable request timeouts for user info proxy. - Enhance test coverage for user info error cases and session management. --- bff/bff_app/routes/auth.py | 37 +++++++++++++++++++--- bff/tests/test_auth_userinfo.py | 54 ++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index fada058e..8136d0bc 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -250,11 +250,38 @@ def auth_userinfo(): print("-> /proxy/api/auth/userinfo") settings = get_settings() - userinfo = requests.get( - settings.oauth_endpoint_userinfo, - headers={"Authorization": f"Bearer {session['token']['access_token']}"}, - ) - return userinfo.json() + token = session.get("token") + access_token = token.get("access_token") if isinstance(token, dict) else None + if not access_token: + session.clear() + return jsonify({"message": "Unauthorized"}), 401 + + try: + userinfo = requests.get( + settings.oauth_endpoint_userinfo, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), + ) + except requests.exceptions.RequestException as exc: + print(f"USERINFO REQUEST FAILED: {exc}") + return jsonify({"message": "Upstream connection error"}), 502 + + if userinfo.status_code in {401, 403}: + session.clear() + return jsonify({"message": "Unauthorized"}), userinfo.status_code + + if userinfo.status_code != 200: + print(f"USERINFO REQUEST REJECTED: {userinfo.status_code}") + return jsonify({"message": "Failed to fetch user info"}), 502 + + try: + return jsonify(userinfo.json()) + except ValueError: + print("USERINFO RESPONSE WAS NOT VALID JSON") + return jsonify({"message": "Invalid upstream response"}), 502 @auth_bp.route("/session", methods=["GET"]) diff --git a/bff/tests/test_auth_userinfo.py b/bff/tests/test_auth_userinfo.py index 4472bf5a..ac19fdad 100644 --- a/bff/tests/test_auth_userinfo.py +++ b/bff/tests/test_auth_userinfo.py @@ -1,12 +1,19 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import requests + from bff_app.routes import auth as auth_routes def test_userinfo_proxies_to_auth_server(client, monkeypatch): # Mock userinfo response from auth server. - mock_get = MagicMock(return_value=SimpleNamespace(json=lambda: {"sub": "user-1"})) + mock_get = MagicMock( + return_value=SimpleNamespace( + status_code=200, + json=lambda: {"sub": "user-1"}, + ) + ) monkeypatch.setattr(auth_routes.requests, "get", mock_get) with client.session_transaction() as sess: @@ -17,3 +24,48 @@ def test_userinfo_proxies_to_auth_server(client, monkeypatch): assert res.status_code == 200 assert res.get_json() == {"sub": "user-1"} mock_get.assert_called_once() + assert mock_get.call_args.kwargs["timeout"] == (3.0, 30.0) + + +def test_userinfo_missing_session_token_returns_401(client, monkeypatch): + mock_get = MagicMock() + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + + res = client.get("/proxy/api/auth/userinfo") + + assert res.status_code == 401 + assert res.get_json() == {"message": "Unauthorized"} + mock_get.assert_not_called() + + +def test_userinfo_handles_upstream_request_errors(client, monkeypatch): + mock_get = MagicMock(side_effect=requests.exceptions.Timeout("timeout")) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "access-token"} + + res = client.get("/proxy/api/auth/userinfo") + + assert res.status_code == 502 + assert res.get_json() == {"message": "Upstream connection error"} + + +def test_userinfo_clears_session_on_upstream_401(client, monkeypatch): + mock_get = MagicMock( + return_value=SimpleNamespace( + status_code=401, + json=lambda: {"error": "invalid_token"}, + ) + ) + monkeypatch.setattr(auth_routes.requests, "get", mock_get) + + with client.session_transaction() as sess: + sess["token"] = {"access_token": "access-token"} + + res = client.get("/proxy/api/auth/userinfo") + + assert res.status_code == 401 + assert res.get_json() == {"message": "Unauthorized"} + with client.session_transaction() as sess: + assert "token" not in sess From b231dd3cb35eff77ea650f31ae996cebb9e42e7a Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 11:45:17 +0100 Subject: [PATCH 16/24] Replace `print` statements with structured logging, add timeout assertions in tests --- bff/bff_app/routes/auth.py | 37 +++++++++++++++++++-------------- bff/bff_app/routes/health.py | 4 ++-- bff/bff_app/routes/proxy.py | 17 ++++++++++----- bff/bff_app/services/auth.py | 13 +++++++++--- bff/tests/test_auth_session.py | 2 ++ bff/tests/test_proxy_request.py | 1 + 6 files changed, 48 insertions(+), 26 deletions(-) diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index 8136d0bc..de5b353f 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -5,7 +5,7 @@ import requests import secrets from authlib.integrations.requests_client import OAuth2Session -from flask import jsonify, redirect, request, session +from flask import current_app, jsonify, redirect, request, session from flask_smorest import Blueprint from bff_app.services.auth import get_settings, refresh_access_token @@ -51,7 +51,7 @@ def login(): ``redirect`` key. :rtype: flask.Response """ - print("-> /proxy/api/auth/login") + current_app.logger.debug("Handling /proxy/api/auth/login") settings = get_settings() client = OAuth2Session( @@ -98,7 +98,7 @@ def login_cb(): Redirect response to the configured frontend URL. :rtype: flask.Response """ - print("-> /proxy/api/auth/callback") + current_app.logger.debug("Handling /proxy/api/auth/callback") settings = get_settings() request_state = request.args.get("state") @@ -107,20 +107,22 @@ def login_cb(): code_verifier = session.get("cv") if not session_state or not request_state: - print("Missing OAuth state during callback") + current_app.logger.warning("Missing OAuth state during callback") session.clear() return redirect(settings.frontend_redirect, code=302) if request_state != session_state: - print("State mismatch") + current_app.logger.warning("OAuth callback state mismatch") session.clear() return redirect(settings.frontend_redirect, code=302) if not authorization_code or not code_verifier: - print("Missing authorization code or PKCE verifier during callback") + current_app.logger.warning( + "Missing authorization code or PKCE verifier during callback" + ) session.clear() return redirect(settings.frontend_redirect, code=302) - print("State match !") - print("Performing Code Exchange") + current_app.logger.debug("OAuth callback state validated") + current_app.logger.debug("Performing OAuth code exchange") client = OAuth2Session( settings.oauth_client_id, @@ -131,7 +133,7 @@ def login_cb(): ) authorization_response = request.url - print("Authorization Code : ", authorization_response) + current_app.logger.debug("OAuth authorization response received") try: token = client.fetch_token( @@ -140,7 +142,7 @@ def login_cb(): code_verifier=code_verifier, ) except Exception as exc: - print(f"Token exchange failed: {exc}") + current_app.logger.warning("OAuth token exchange failed: %s", exc) session.clear() return redirect(settings.frontend_redirect, code=302) @@ -177,7 +179,7 @@ def logout(): JSON payload confirming logout success. :rtype: flask.Response """ - print("-> /proxy/api/auth/logout") + current_app.logger.debug("Handling /proxy/api/auth/logout") settings = get_settings() requests.post( @@ -247,7 +249,7 @@ def auth_userinfo(): JSON object returned by the upstream userinfo endpoint. :rtype: dict """ - print("-> /proxy/api/auth/userinfo") + current_app.logger.debug("Handling /proxy/api/auth/userinfo") settings = get_settings() token = session.get("token") @@ -266,7 +268,7 @@ def auth_userinfo(): ), ) except requests.exceptions.RequestException as exc: - print(f"USERINFO REQUEST FAILED: {exc}") + current_app.logger.warning("Userinfo request failed: %s", exc) return jsonify({"message": "Upstream connection error"}), 502 if userinfo.status_code in {401, 403}: @@ -274,13 +276,16 @@ def auth_userinfo(): return jsonify({"message": "Unauthorized"}), userinfo.status_code if userinfo.status_code != 200: - print(f"USERINFO REQUEST REJECTED: {userinfo.status_code}") + current_app.logger.warning( + "Userinfo request rejected with status %s", + userinfo.status_code, + ) return jsonify({"message": "Failed to fetch user info"}), 502 try: return jsonify(userinfo.json()) except ValueError: - print("USERINFO RESPONSE WAS NOT VALID JSON") + current_app.logger.warning("Userinfo response was not valid JSON") return jsonify({"message": "Invalid upstream response"}), 502 @@ -317,7 +322,7 @@ def check_session(): JSON object containing ``{"session": }``. :rtype: flask.Response """ - print("-> /proxy/api/auth/session") + current_app.logger.debug("Handling /proxy/api/auth/session") settings = get_settings() is_valid_session = False diff --git a/bff/bff_app/routes/health.py b/bff/bff_app/routes/health.py index 86f85477..e2fba4cc 100644 --- a/bff/bff_app/routes/health.py +++ b/bff/bff_app/routes/health.py @@ -2,7 +2,7 @@ from __future__ import annotations -from flask import jsonify +from flask import current_app, jsonify from flask_smorest import Blueprint health_bp = Blueprint( @@ -37,5 +37,5 @@ def ping(): :returns: ``{"message": "pong"}``. :rtype: flask.Response """ - print("-> /ping") + current_app.logger.debug("Handling /ping") return jsonify({"message": "pong"}) diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py index 59946b87..334f4788 100644 --- a/bff/bff_app/routes/proxy.py +++ b/bff/bff_app/routes/proxy.py @@ -3,7 +3,7 @@ from __future__ import annotations import requests -from flask import Response, request, session +from flask import Response, current_app, request, session from flask_smorest import Blueprint from bff_app.services.auth import get_settings, refresh_access_token @@ -101,7 +101,7 @@ def proxy_request(rest_of_url: str): ``401`` with ``invalid_token``. - Drops hop-by-hop and duplicate CORS headers from upstream response. """ - print("-> /proxy/api/request/" + rest_of_url) + current_app.logger.debug("Handling /proxy/api/request/%s", rest_of_url) settings = get_settings() headers = _build_upstream_headers() @@ -110,7 +110,10 @@ def proxy_request(rest_of_url: str): if "token" in session and "access_token" in session["token"]: headers["Authorization"] = f"Bearer {session['token']['access_token']}" else: - print("ACCESS TOKEN IS MISSING") + current_app.logger.warning( + "Access token missing in session for proxied request: %s", + rest_of_url, + ) target_url = f"{settings.backend_endpoint.rstrip('/')}/{rest_of_url.lstrip('/')}" @@ -131,7 +134,7 @@ def forward_request(): try: response = forward_request() except requests.exceptions.RequestException as exc: - print(f"REST proxy error: {exc}") + current_app.logger.warning("REST proxy error for %s: %s", rest_of_url, exc) return Response("Upstream connection error", status=502) www_authenticate = response.headers.get("www-authenticate", "") @@ -141,7 +144,11 @@ def forward_request(): try: response = forward_request() except requests.exceptions.RequestException as exc: - print(f"REST proxy error after refresh: {exc}") + current_app.logger.warning( + "REST proxy error after token refresh for %s: %s", + rest_of_url, + exc, + ) return Response("Upstream connection error", status=502) excluded_headers = [ diff --git a/bff/bff_app/services/auth.py b/bff/bff_app/services/auth.py index 21b8ae82..5b36bc1f 100644 --- a/bff/bff_app/services/auth.py +++ b/bff/bff_app/services/auth.py @@ -31,7 +31,7 @@ def refresh_access_token() -> bool: refresh_token = session.get("token", {}).get("refresh_token") if not refresh_token: - print("REFRESH TOKEN IS MISSING") + current_app.logger.warning("Refresh token is missing from session") return False try: @@ -43,13 +43,20 @@ def refresh_access_token() -> bool: "client_id": settings.oauth_client_id, "client_secret": settings.oauth_client_secret, }, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), ) except requests.exceptions.RequestException as exc: - print(f"REFRESH TOKEN REQUEST FAILED: {exc}") + current_app.logger.warning("Refresh token request failed: %s", exc) return False if response.status_code != 200: - print(f"REFRESH TOKEN REQUEST REJECTED: {response.status_code}") + current_app.logger.warning( + "Refresh token request rejected with status %s", + response.status_code, + ) return False session["token"] = response.json() diff --git a/bff/tests/test_auth_session.py b/bff/tests/test_auth_session.py index 5fee4580..54ee512e 100644 --- a/bff/tests/test_auth_session.py +++ b/bff/tests/test_auth_session.py @@ -44,6 +44,7 @@ def test_session_refreshes_and_recovers(client, monkeypatch): assert res.status_code == 200 assert res.get_json() == {"session": True} + assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) def test_session_false_clears_cookie_on_failure(client, monkeypatch): @@ -63,6 +64,7 @@ def test_session_false_clears_cookie_on_failure(client, monkeypatch): assert res.status_code == 200 assert res.get_json() == {"session": False} + assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) with client.session_transaction() as sess: assert "token" not in sess diff --git a/bff/tests/test_proxy_request.py b/bff/tests/test_proxy_request.py index d3e8b9b2..5a21a74d 100644 --- a/bff/tests/test_proxy_request.py +++ b/bff/tests/test_proxy_request.py @@ -95,6 +95,7 @@ def test_proxy_request_retries_on_invalid_token(client, monkeypatch): assert res.status_code == 200 assert res.data == b'{"ok":true}' assert mock_request.call_count == 2 + assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) first_call_timeout = mock_request.call_args_list[0].kwargs["timeout"] second_call_timeout = mock_request.call_args_list[1].kwargs["timeout"] assert first_call_timeout == (3.0, 30.0) From 15e6a002ffdd90fb769da543147076643620d877 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 12:21:39 +0100 Subject: [PATCH 17/24] Handle logout timeouts and missing tokens gracefully - Add error handling for upstream logout timeouts and invalid responses. - Skip logout call if `id_token` is missing in session. - Introduce configurable timeouts for logout requests. - Enhance test coverage for logout scenarios, including timeouts and missing tokens. --- bff/bff_app/routes/auth.py | 27 +++++++++++++++++++++---- bff/tests/test_auth_logout.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index de5b353f..7c16a5a1 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -182,10 +182,29 @@ def logout(): current_app.logger.debug("Handling /proxy/api/auth/logout") settings = get_settings() - requests.post( - settings.oauth_endpoint_logout, - {"id_token_hint": session["token"]["id_token"]}, - ) + token = session.get("token") + id_token = token.get("id_token") if isinstance(token, dict) else None + + if id_token: + try: + response = requests.post( + settings.oauth_endpoint_logout, + {"id_token_hint": id_token}, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), + ) + if response.status_code >= 400: + current_app.logger.warning( + "Logout endpoint returned status %s", + response.status_code, + ) + except requests.exceptions.RequestException as exc: + current_app.logger.warning("Logout request failed: %s", exc) + else: + current_app.logger.debug("No id_token in session; skipping upstream logout call") + session.clear() return jsonify({"message": "logout successful"}) diff --git a/bff/tests/test_auth_logout.py b/bff/tests/test_auth_logout.py index 580517ac..fa630b67 100644 --- a/bff/tests/test_auth_logout.py +++ b/bff/tests/test_auth_logout.py @@ -1,6 +1,8 @@ from types import SimpleNamespace from unittest.mock import MagicMock +import requests + from bff_app.routes import auth as auth_routes @@ -17,7 +19,43 @@ def test_logout_revokes_tokens_and_clears_session(client, monkeypatch): assert res.status_code == 200 assert res.get_json() == {"message": "logout successful"} mock_post.assert_called_once() + assert mock_post.call_args.args[0] == "http://auth.test/logout" + assert mock_post.call_args.args[1] == {"id_token_hint": "id-token"} + assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) # Session should be empty after logout. with client.session_transaction() as sess: assert "token" not in sess + + +def test_logout_without_token_still_clears_session(client, monkeypatch): + mock_post = MagicMock() + monkeypatch.setattr(auth_routes.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["state"] = "stale-state" + + res = client.get("/proxy/api/auth/logout") + + assert res.status_code == 200 + assert res.get_json() == {"message": "logout successful"} + mock_post.assert_not_called() + with client.session_transaction() as sess: + assert len(sess.keys()) == 0 + + +def test_logout_handles_upstream_timeout_and_still_clears_session(client, monkeypatch): + mock_post = MagicMock(side_effect=requests.exceptions.Timeout("timeout")) + monkeypatch.setattr(auth_routes.requests, "post", mock_post) + + with client.session_transaction() as sess: + sess["token"] = {"id_token": "id-token"} + + res = client.get("/proxy/api/auth/logout") + + assert res.status_code == 200 + assert res.get_json() == {"message": "logout successful"} + mock_post.assert_called_once() + assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) + with client.session_transaction() as sess: + assert "token" not in sess From d7b96e66ffe2da1291b0d33af3227e4678d3bea7 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 12:31:16 +0100 Subject: [PATCH 18/24] Set default container port to 5000 in docker-compose configuration --- bff/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bff/docker-compose.yml b/bff/docker-compose.yml index 9e9d9835..57a7c000 100644 --- a/bff/docker-compose.yml +++ b/bff/docker-compose.yml @@ -6,9 +6,9 @@ services: context: . dockerfile: Dockerfile args: - PORT: ${PORT} + PORT: ${PORT:-5000} ports: - - "${PORT}:${PORT}" + - "${PORT:-5000}:${PORT:-5000}" env_file: - .env container_name: bff-flask From 4a1f082803e17f8f93f053ad4f2a404420cf0186 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 13:22:29 +0100 Subject: [PATCH 19/24] Encrypt session tokens to enhance security --- bff/.env.example | 1 + bff/README.md | 1 + bff/bff_app/openapi/generate.py | 1 + bff/bff_app/routes/auth.py | 32 +++++++++++++---- bff/bff_app/routes/proxy.py | 14 +++++--- bff/bff_app/services/auth.py | 64 +++++++++++++++++++++++++++++++-- bff/bff_app/settings.py | 16 +++++++++ bff/tests/conftest.py | 4 +++ bff/tests/test_auth_callback.py | 4 ++- bff/tests/test_auth_session.py | 5 +++ bff/tests/test_settings.py | 18 ++++++++++ 11 files changed, 145 insertions(+), 15 deletions(-) diff --git a/bff/.env.example b/bff/.env.example index 0cadf18c..db880f84 100644 --- a/bff/.env.example +++ b/bff/.env.example @@ -31,6 +31,7 @@ SESSION_COOKIE_PATH="/" SESSION_COOKIE_HTTPONLY="True" SESSION_COOKIE_SECURE="True" SESSION_COOKIE_SAMESITE="Strict" +SESSION_TOKEN_ENCRYPTION_KEY="replace-with-fernet-key" # Server we're proxying BACKEND_ENDPOINT="http://host.docker.internal:8080/api" diff --git a/bff/README.md b/bff/README.md index 77450260..f3bebf22 100644 --- a/bff/README.md +++ b/bff/README.md @@ -37,6 +37,7 @@ For more information on the BFF architecture, see: - `SESSION_COOKIE_HTTPONLY` - `SESSION_COOKIE_SECURE` - `SESSION_COOKIE_SAMESITE` +- `SESSION_TOKEN_ENCRYPTION_KEY` (Fernet key used to encrypt access/refresh tokens in the session cookie) - Optional values: - `BACKEND_CONNECT_TIMEOUT_SECONDS` (default: `3`) - `BACKEND_READ_TIMEOUT_SECONDS` (default: `30`) diff --git a/bff/bff_app/openapi/generate.py b/bff/bff_app/openapi/generate.py index b5cf2669..c9102f8b 100644 --- a/bff/bff_app/openapi/generate.py +++ b/bff/bff_app/openapi/generate.py @@ -31,6 +31,7 @@ def _spec_settings() -> BffSettings: oauth_endpoint_logout="http://auth.example/logout", oauth_login_redirect_uri="http://localhost:5022/proxy/api/auth/callback", frontend_redirect="http://localhost:5178", + session_token_encryption_key="J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", ) diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index 7c16a5a1..59a5ce4d 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -8,7 +8,12 @@ from flask import current_app, jsonify, redirect, request, session from flask_smorest import Blueprint -from bff_app.services.auth import get_settings, refresh_access_token +from bff_app.services.auth import ( + get_session_token, + get_settings, + refresh_access_token, + store_session_token, +) auth_bp = Blueprint( "auth", @@ -146,7 +151,7 @@ def login_cb(): session.clear() return redirect(settings.frontend_redirect, code=302) - session["token"] = token + store_session_token(token) session.pop("cv", None) session.pop("state", None) return redirect(settings.frontend_redirect, code=302) @@ -182,7 +187,7 @@ def logout(): current_app.logger.debug("Handling /proxy/api/auth/logout") settings = get_settings() - token = session.get("token") + token = get_session_token() id_token = token.get("id_token") if isinstance(token, dict) else None if id_token: @@ -271,7 +276,7 @@ def auth_userinfo(): current_app.logger.debug("Handling /proxy/api/auth/userinfo") settings = get_settings() - token = session.get("token") + token = get_session_token() access_token = token.get("access_token") if isinstance(token, dict) else None if not access_token: session.clear() @@ -346,16 +351,29 @@ def check_session(): is_valid_session = False - if "token" in session: + token = get_session_token() + if token and "access_token" in token: userinfo = requests.get( settings.oauth_endpoint_userinfo, - headers={"Authorization": f"Bearer {session['token']['access_token']}"}, + headers={"Authorization": f"Bearer {token['access_token']}"}, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), ) is_valid_session = userinfo.status_code == 200 if not is_valid_session and refresh_access_token(): + refreshed_token = get_session_token() + if not refreshed_token or "access_token" not in refreshed_token: + session.clear() + return jsonify({"session": False}) userinfo = requests.get( settings.oauth_endpoint_userinfo, - headers={"Authorization": f"Bearer {session['token']['access_token']}"}, + headers={"Authorization": f"Bearer {refreshed_token['access_token']}"}, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), ) is_valid_session = userinfo.status_code == 200 diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py index 334f4788..46863c25 100644 --- a/bff/bff_app/routes/proxy.py +++ b/bff/bff_app/routes/proxy.py @@ -3,10 +3,10 @@ from __future__ import annotations import requests -from flask import Response, current_app, request, session +from flask import Response, current_app, request from flask_smorest import Blueprint -from bff_app.services.auth import get_settings, refresh_access_token +from bff_app.services.auth import get_session_token, get_settings, refresh_access_token proxy_bp = Blueprint( "proxy", @@ -107,8 +107,9 @@ def proxy_request(rest_of_url: str): headers = _build_upstream_headers() payload = request.get_data() - if "token" in session and "access_token" in session["token"]: - headers["Authorization"] = f"Bearer {session['token']['access_token']}" + session_token = get_session_token() + if session_token and "access_token" in session_token: + headers["Authorization"] = f"Bearer {session_token['access_token']}" else: current_app.logger.warning( "Access token missing in session for proxied request: %s", @@ -140,7 +141,10 @@ def forward_request(): www_authenticate = response.headers.get("www-authenticate", "") if response.status_code == 401 and "invalid_token" in www_authenticate: if refresh_access_token(): - headers["Authorization"] = f"Bearer {session['token']['access_token']}" + refreshed_token = get_session_token() + if not refreshed_token or "access_token" not in refreshed_token: + return Response("Upstream connection error", status=502) + headers["Authorization"] = f"Bearer {refreshed_token['access_token']}" try: response = forward_request() except requests.exceptions.RequestException as exc: diff --git a/bff/bff_app/services/auth.py b/bff/bff_app/services/auth.py index 5b36bc1f..bab1fa7d 100644 --- a/bff/bff_app/services/auth.py +++ b/bff/bff_app/services/auth.py @@ -2,11 +2,17 @@ from __future__ import annotations +from typing import Any, Mapping + import requests +from cryptography.fernet import Fernet, InvalidToken from flask import current_app, session from bff_app.settings import BffSettings +ENCRYPTED_TOKEN_PREFIX = "enc::" +SENSITIVE_TOKEN_FIELDS = frozenset({"access_token", "refresh_token"}) + def get_settings() -> BffSettings: """Return resolved application settings from Flask extensions. @@ -17,6 +23,59 @@ def get_settings() -> BffSettings: return current_app.extensions["bff_settings"] +def _get_token_cipher() -> Fernet: + """Build Fernet cipher from configured token encryption key.""" + settings = get_settings() + return Fernet(settings.session_token_encryption_key.encode("utf-8")) + + +def store_session_token(token: Mapping[str, Any]) -> None: + """Store OAuth token payload in session with encrypted sensitive fields.""" + cipher = _get_token_cipher() + encrypted_token = dict(token) + for field in SENSITIVE_TOKEN_FIELDS: + value = encrypted_token.get(field) + if isinstance(value, str): + encrypted = cipher.encrypt(value.encode("utf-8")).decode("utf-8") + encrypted_token[field] = f"{ENCRYPTED_TOKEN_PREFIX}{encrypted}" + session["token"] = encrypted_token + + +def get_session_token() -> dict[str, Any] | None: + """Return OAuth token payload from session with sensitive fields decrypted.""" + raw_token = session.get("token") + if not isinstance(raw_token, Mapping): + return None + + token = dict(raw_token) + cipher = _get_token_cipher() + for field in SENSITIVE_TOKEN_FIELDS: + value = token.get(field) + if value is None: + continue + if not isinstance(value, str): + current_app.logger.warning( + "Session token field %s has unexpected type %s", + field, + type(value).__name__, + ) + session.clear() + return None + if not value.startswith(ENCRYPTED_TOKEN_PREFIX): + # Backward compatibility for pre-encryption sessions. + continue + + encrypted_value = value.removeprefix(ENCRYPTED_TOKEN_PREFIX) + try: + token[field] = cipher.decrypt(encrypted_value.encode("utf-8")).decode("utf-8") + except InvalidToken: + current_app.logger.warning("Failed to decrypt session token field %s", field) + session.clear() + return None + + return token + + def refresh_access_token() -> bool: """Refresh the access token using the current session refresh token. @@ -29,7 +88,8 @@ def refresh_access_token() -> bool: """ settings = get_settings() - refresh_token = session.get("token", {}).get("refresh_token") + session_token = get_session_token() or {} + refresh_token = session_token.get("refresh_token") if not refresh_token: current_app.logger.warning("Refresh token is missing from session") return False @@ -59,5 +119,5 @@ def refresh_access_token() -> bool: ) return False - session["token"] = response.json() + store_session_token(response.json()) return True diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py index 118ac0b9..2623393e 100644 --- a/bff/bff_app/settings.py +++ b/bff/bff_app/settings.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import Mapping +from cryptography.fernet import Fernet + class SettingsValidationError(ValueError): """Raised when required BFF environment configuration is missing.""" @@ -78,6 +80,9 @@ class BffSettings: Connect timeout in seconds for backend proxy requests. :ivar backend_read_timeout_seconds: Read timeout in seconds for backend proxy requests. + :ivar session_token_encryption_key: + Fernet key used to encrypt/decrypt OAuth access and refresh tokens + stored in the signed session cookie. """ flask_secret_key: str session_cookie_name: str @@ -98,6 +103,7 @@ class BffSettings: frontend_redirect: str backend_connect_timeout_seconds: float = 3.0 backend_read_timeout_seconds: float = 30.0 + session_token_encryption_key: str = "" REQUIRED_ENV_VARS: tuple[str, ...] = ( @@ -107,6 +113,7 @@ class BffSettings: "SESSION_COOKIE_HTTPONLY", "SESSION_COOKIE_SECURE", "SESSION_COOKIE_SAMESITE", + "SESSION_TOKEN_ENCRYPTION_KEY", "CORS_ALLOWED_ORIGIN", "BACKEND_ENDPOINT", "OAUTH_CLIENT_ID", @@ -151,6 +158,14 @@ def load_settings_from_env() -> BffSettings: raw_env = {name: os.getenv(name) for name in REQUIRED_ENV_VARS} validate_required_env(raw_env) + session_token_encryption_key = raw_env["SESSION_TOKEN_ENCRYPTION_KEY"] + try: + Fernet(session_token_encryption_key.encode("utf-8")) + except Exception as exc: + raise SettingsValidationError( + "SESSION_TOKEN_ENCRYPTION_KEY must be a valid Fernet key." + ) from exc + return BffSettings( flask_secret_key=raw_env["FLASK_SECRET_KEY"], session_cookie_name=raw_env["SESSION_COOKIE_NAME"], @@ -177,4 +192,5 @@ def load_settings_from_env() -> BffSettings: "BACKEND_READ_TIMEOUT_SECONDS", 30.0, ), + session_token_encryption_key=session_token_encryption_key, ) diff --git a/bff/tests/conftest.py b/bff/tests/conftest.py index b9c9fa4b..56380ad8 100644 --- a/bff/tests/conftest.py +++ b/bff/tests/conftest.py @@ -21,6 +21,10 @@ def app(monkeypatch): monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") + monkeypatch.setenv( + "SESSION_TOKEN_ENCRYPTION_KEY", + "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", + ) monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://example.test") # Backend proxy configuration diff --git a/bff/tests/test_auth_callback.py b/bff/tests/test_auth_callback.py index e2e75f32..21eb3165 100644 --- a/bff/tests/test_auth_callback.py +++ b/bff/tests/test_auth_callback.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock from bff_app.routes import auth as auth_routes +from bff_app.services import auth as auth_service def _fake_oauth_session(state="state-123", token=None): @@ -31,7 +32,8 @@ def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): # Token should be saved to the session after the code exchange. with client.session_transaction() as sess: - assert sess["token"]["access_token"] == "tok" + assert sess["token"]["access_token"] != "tok" + assert sess["token"]["access_token"].startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) assert "state" not in sess assert "cv" not in sess diff --git a/bff/tests/test_auth_session.py b/bff/tests/test_auth_session.py index 54ee512e..141ad202 100644 --- a/bff/tests/test_auth_session.py +++ b/bff/tests/test_auth_session.py @@ -45,6 +45,11 @@ def test_session_refreshes_and_recovers(client, monkeypatch): assert res.status_code == 200 assert res.get_json() == {"session": True} assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) + with client.session_transaction() as sess: + encrypted_access = sess["token"]["access_token"] + encrypted_refresh = sess["token"]["refresh_token"] + assert encrypted_access.startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) + assert encrypted_refresh.startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) def test_session_false_clears_cookie_on_failure(client, monkeypatch): diff --git a/bff/tests/test_settings.py b/bff/tests/test_settings.py index ea1f31ee..03f6dc92 100644 --- a/bff/tests/test_settings.py +++ b/bff/tests/test_settings.py @@ -10,6 +10,10 @@ def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") + monkeypatch.setenv( + "SESSION_TOKEN_ENCRYPTION_KEY", + "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", + ) monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://frontend.test") monkeypatch.setenv("BACKEND_ENDPOINT", "http://backend.test/api") monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id") @@ -57,6 +61,7 @@ def test_load_settings_from_env_returns_validated_settings( assert settings.session_cookie_httponly is True assert settings.session_cookie_secure is False assert settings.session_cookie_samesite == "Lax" + assert settings.session_token_encryption_key == "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=" assert settings.cors_allowed_origin == "http://frontend.test" assert settings.backend_endpoint == "http://backend.test/api" assert settings.oauth_client_id == "client-id" @@ -96,3 +101,16 @@ def test_load_settings_from_env_rejects_invalid_backend_timeouts( match="BACKEND_CONNECT_TIMEOUT_SECONDS must be greater than 0", ): load_settings_from_env() + + +def test_load_settings_from_env_rejects_invalid_encryption_key( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + monkeypatch.setenv("SESSION_TOKEN_ENCRYPTION_KEY", "not-a-fernet-key") + + with pytest.raises( + SettingsValidationError, + match="SESSION_TOKEN_ENCRYPTION_KEY must be a valid Fernet key", + ): + load_settings_from_env() From bee82c3dabf71c14110b09cc37920b20c8bafbf6 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Fri, 6 Mar 2026 17:04:45 +0100 Subject: [PATCH 20/24] Revert cookie encryption in a first phase --- bff/.env.example | 1 - bff/README.md | 2 +- bff/bff_app/openapi/generate.py | 1 - bff/bff_app/services/auth.py | 63 +++++++-------------------------- bff/bff_app/settings.py | 16 --------- bff/tests/conftest.py | 4 --- bff/tests/test_auth_callback.py | 4 +-- bff/tests/test_auth_session.py | 6 ++-- bff/tests/test_settings.py | 18 ---------- 9 files changed, 17 insertions(+), 98 deletions(-) diff --git a/bff/.env.example b/bff/.env.example index db880f84..0cadf18c 100644 --- a/bff/.env.example +++ b/bff/.env.example @@ -31,7 +31,6 @@ SESSION_COOKIE_PATH="/" SESSION_COOKIE_HTTPONLY="True" SESSION_COOKIE_SECURE="True" SESSION_COOKIE_SAMESITE="Strict" -SESSION_TOKEN_ENCRYPTION_KEY="replace-with-fernet-key" # Server we're proxying BACKEND_ENDPOINT="http://host.docker.internal:8080/api" diff --git a/bff/README.md b/bff/README.md index f3bebf22..c5796df7 100644 --- a/bff/README.md +++ b/bff/README.md @@ -37,7 +37,6 @@ For more information on the BFF architecture, see: - `SESSION_COOKIE_HTTPONLY` - `SESSION_COOKIE_SECURE` - `SESSION_COOKIE_SAMESITE` -- `SESSION_TOKEN_ENCRYPTION_KEY` (Fernet key used to encrypt access/refresh tokens in the session cookie) - Optional values: - `BACKEND_CONNECT_TIMEOUT_SECONDS` (default: `3`) - `BACKEND_READ_TIMEOUT_SECONDS` (default: `30`) @@ -76,6 +75,7 @@ docker-compose up --build **Security Notes** - Tokens, PKCE verifier, and OAuth state are stored in Flask's signed session cookie. +- Flask signs the cookie to prevent tampering, but cookie contents are not encrypted by default. - This service does not currently configure Flask-Session/Redis-backed server-side session storage. - Keep `SESSION_COOKIE_SECURE=True` and `SESSION_COOKIE_SAMESITE=Strict` in production. diff --git a/bff/bff_app/openapi/generate.py b/bff/bff_app/openapi/generate.py index c9102f8b..b5cf2669 100644 --- a/bff/bff_app/openapi/generate.py +++ b/bff/bff_app/openapi/generate.py @@ -31,7 +31,6 @@ def _spec_settings() -> BffSettings: oauth_endpoint_logout="http://auth.example/logout", oauth_login_redirect_uri="http://localhost:5022/proxy/api/auth/callback", frontend_redirect="http://localhost:5178", - session_token_encryption_key="J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", ) diff --git a/bff/bff_app/services/auth.py b/bff/bff_app/services/auth.py index bab1fa7d..9bcd37fb 100644 --- a/bff/bff_app/services/auth.py +++ b/bff/bff_app/services/auth.py @@ -5,14 +5,10 @@ from typing import Any, Mapping import requests -from cryptography.fernet import Fernet, InvalidToken from flask import current_app, session from bff_app.settings import BffSettings -ENCRYPTED_TOKEN_PREFIX = "enc::" -SENSITIVE_TOKEN_FIELDS = frozenset({"access_token", "refresh_token"}) - def get_settings() -> BffSettings: """Return resolved application settings from Flask extensions. @@ -23,57 +19,24 @@ def get_settings() -> BffSettings: return current_app.extensions["bff_settings"] -def _get_token_cipher() -> Fernet: - """Build Fernet cipher from configured token encryption key.""" - settings = get_settings() - return Fernet(settings.session_token_encryption_key.encode("utf-8")) - - def store_session_token(token: Mapping[str, Any]) -> None: - """Store OAuth token payload in session with encrypted sensitive fields.""" - cipher = _get_token_cipher() - encrypted_token = dict(token) - for field in SENSITIVE_TOKEN_FIELDS: - value = encrypted_token.get(field) - if isinstance(value, str): - encrypted = cipher.encrypt(value.encode("utf-8")).decode("utf-8") - encrypted_token[field] = f"{ENCRYPTED_TOKEN_PREFIX}{encrypted}" - session["token"] = encrypted_token + """Store OAuth token payload in the signed Flask session cookie.""" + session["token"] = dict(token) def get_session_token() -> dict[str, Any] | None: - """Return OAuth token payload from session with sensitive fields decrypted.""" + """Return OAuth token payload from the signed Flask session cookie.""" raw_token = session.get("token") - if not isinstance(raw_token, Mapping): - return None - - token = dict(raw_token) - cipher = _get_token_cipher() - for field in SENSITIVE_TOKEN_FIELDS: - value = token.get(field) - if value is None: - continue - if not isinstance(value, str): - current_app.logger.warning( - "Session token field %s has unexpected type %s", - field, - type(value).__name__, - ) - session.clear() - return None - if not value.startswith(ENCRYPTED_TOKEN_PREFIX): - # Backward compatibility for pre-encryption sessions. - continue - - encrypted_value = value.removeprefix(ENCRYPTED_TOKEN_PREFIX) - try: - token[field] = cipher.decrypt(encrypted_value.encode("utf-8")).decode("utf-8") - except InvalidToken: - current_app.logger.warning("Failed to decrypt session token field %s", field) - session.clear() - return None - - return token + if isinstance(raw_token, Mapping): + return dict(raw_token) + + if raw_token is not None: + current_app.logger.warning( + "Session token payload has unexpected type %s", + type(raw_token).__name__, + ) + session.pop("token", None) + return None def refresh_access_token() -> bool: diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py index 2623393e..118ac0b9 100644 --- a/bff/bff_app/settings.py +++ b/bff/bff_app/settings.py @@ -6,8 +6,6 @@ from dataclasses import dataclass from typing import Mapping -from cryptography.fernet import Fernet - class SettingsValidationError(ValueError): """Raised when required BFF environment configuration is missing.""" @@ -80,9 +78,6 @@ class BffSettings: Connect timeout in seconds for backend proxy requests. :ivar backend_read_timeout_seconds: Read timeout in seconds for backend proxy requests. - :ivar session_token_encryption_key: - Fernet key used to encrypt/decrypt OAuth access and refresh tokens - stored in the signed session cookie. """ flask_secret_key: str session_cookie_name: str @@ -103,7 +98,6 @@ class BffSettings: frontend_redirect: str backend_connect_timeout_seconds: float = 3.0 backend_read_timeout_seconds: float = 30.0 - session_token_encryption_key: str = "" REQUIRED_ENV_VARS: tuple[str, ...] = ( @@ -113,7 +107,6 @@ class BffSettings: "SESSION_COOKIE_HTTPONLY", "SESSION_COOKIE_SECURE", "SESSION_COOKIE_SAMESITE", - "SESSION_TOKEN_ENCRYPTION_KEY", "CORS_ALLOWED_ORIGIN", "BACKEND_ENDPOINT", "OAUTH_CLIENT_ID", @@ -158,14 +151,6 @@ def load_settings_from_env() -> BffSettings: raw_env = {name: os.getenv(name) for name in REQUIRED_ENV_VARS} validate_required_env(raw_env) - session_token_encryption_key = raw_env["SESSION_TOKEN_ENCRYPTION_KEY"] - try: - Fernet(session_token_encryption_key.encode("utf-8")) - except Exception as exc: - raise SettingsValidationError( - "SESSION_TOKEN_ENCRYPTION_KEY must be a valid Fernet key." - ) from exc - return BffSettings( flask_secret_key=raw_env["FLASK_SECRET_KEY"], session_cookie_name=raw_env["SESSION_COOKIE_NAME"], @@ -192,5 +177,4 @@ def load_settings_from_env() -> BffSettings: "BACKEND_READ_TIMEOUT_SECONDS", 30.0, ), - session_token_encryption_key=session_token_encryption_key, ) diff --git a/bff/tests/conftest.py b/bff/tests/conftest.py index 56380ad8..b9c9fa4b 100644 --- a/bff/tests/conftest.py +++ b/bff/tests/conftest.py @@ -21,10 +21,6 @@ def app(monkeypatch): monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") - monkeypatch.setenv( - "SESSION_TOKEN_ENCRYPTION_KEY", - "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", - ) monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://example.test") # Backend proxy configuration diff --git a/bff/tests/test_auth_callback.py b/bff/tests/test_auth_callback.py index 21eb3165..e2e75f32 100644 --- a/bff/tests/test_auth_callback.py +++ b/bff/tests/test_auth_callback.py @@ -1,7 +1,6 @@ from unittest.mock import MagicMock from bff_app.routes import auth as auth_routes -from bff_app.services import auth as auth_service def _fake_oauth_session(state="state-123", token=None): @@ -32,8 +31,7 @@ def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): # Token should be saved to the session after the code exchange. with client.session_transaction() as sess: - assert sess["token"]["access_token"] != "tok" - assert sess["token"]["access_token"].startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) + assert sess["token"]["access_token"] == "tok" assert "state" not in sess assert "cv" not in sess diff --git a/bff/tests/test_auth_session.py b/bff/tests/test_auth_session.py index 141ad202..b3d587ff 100644 --- a/bff/tests/test_auth_session.py +++ b/bff/tests/test_auth_session.py @@ -46,10 +46,8 @@ def test_session_refreshes_and_recovers(client, monkeypatch): assert res.get_json() == {"session": True} assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) with client.session_transaction() as sess: - encrypted_access = sess["token"]["access_token"] - encrypted_refresh = sess["token"]["refresh_token"] - assert encrypted_access.startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) - assert encrypted_refresh.startswith(auth_service.ENCRYPTED_TOKEN_PREFIX) + assert sess["token"]["access_token"] == "new-access-token" + assert sess["token"]["refresh_token"] == "new-refresh-token" def test_session_false_clears_cookie_on_failure(client, monkeypatch): diff --git a/bff/tests/test_settings.py b/bff/tests/test_settings.py index 03f6dc92..ea1f31ee 100644 --- a/bff/tests/test_settings.py +++ b/bff/tests/test_settings.py @@ -10,10 +10,6 @@ def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") monkeypatch.setenv("SESSION_COOKIE_SECURE", "False") monkeypatch.setenv("SESSION_COOKIE_SAMESITE", "Lax") - monkeypatch.setenv( - "SESSION_TOKEN_ENCRYPTION_KEY", - "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=", - ) monkeypatch.setenv("CORS_ALLOWED_ORIGIN", "http://frontend.test") monkeypatch.setenv("BACKEND_ENDPOINT", "http://backend.test/api") monkeypatch.setenv("OAUTH_CLIENT_ID", "client-id") @@ -61,7 +57,6 @@ def test_load_settings_from_env_returns_validated_settings( assert settings.session_cookie_httponly is True assert settings.session_cookie_secure is False assert settings.session_cookie_samesite == "Lax" - assert settings.session_token_encryption_key == "J5a6gijR8f2m6fRgvM_6DULYveoxW48UUfGzSy0RKZg=" assert settings.cors_allowed_origin == "http://frontend.test" assert settings.backend_endpoint == "http://backend.test/api" assert settings.oauth_client_id == "client-id" @@ -101,16 +96,3 @@ def test_load_settings_from_env_rejects_invalid_backend_timeouts( match="BACKEND_CONNECT_TIMEOUT_SECONDS must be greater than 0", ): load_settings_from_env() - - -def test_load_settings_from_env_rejects_invalid_encryption_key( - monkeypatch: pytest.MonkeyPatch, -): - _set_required_env(monkeypatch) - monkeypatch.setenv("SESSION_TOKEN_ENCRYPTION_KEY", "not-a-fernet-key") - - with pytest.raises( - SettingsValidationError, - match="SESSION_TOKEN_ENCRYPTION_KEY must be a valid Fernet key", - ): - load_settings_from_env() From 4425fc0ecd22e9b31a19741ad4f13898369062ed Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Mon, 9 Mar 2026 15:15:35 +0100 Subject: [PATCH 21/24] Encrypt OAuth tokens into HttpOnly cookies and update session handling - Introduced encrypted cookies for OAuth tokens, replacing in-session storage. - Updated settings to include `TOKEN_COOKIE_ENCRYPTION_KEY` for encryption. - Enhanced `/auth/session`, `/auth/logout`, and `/proxy/api` routes to operate with token cookies. - Updated tests and fixtures to align with the new cookie-based approach. - Documented changes in `.env.example` and `README.md`. --- bff/.env.example | 3 + bff/README.md | 21 +- bff/bff_app/__init__.py | 6 +- bff/bff_app/openapi/generate.py | 1 + bff/bff_app/openapi/openapi.yaml | 10 +- bff/bff_app/routes/auth.py | 160 +++++++++++---- bff/bff_app/routes/proxy.py | 57 +++++- bff/bff_app/services/auth.py | 80 +++++--- bff/bff_app/services/token_cookies.py | 283 ++++++++++++++++++++++++++ bff/bff_app/settings.py | 32 +++ bff/pyproject.toml | 1 + bff/tests/conftest.py | 48 +++++ bff/tests/test_auth_callback.py | 87 ++++++-- bff/tests/test_auth_logout.py | 20 +- bff/tests/test_auth_session.py | 55 +++-- bff/tests/test_auth_userinfo.py | 37 +++- bff/tests/test_proxy_request.py | 29 ++- bff/tests/test_settings.py | 18 ++ bff/tests/test_token_cookies.py | 94 +++++++++ bff/uv.lock | 2 + 20 files changed, 894 insertions(+), 150 deletions(-) create mode 100644 bff/bff_app/services/token_cookies.py create mode 100644 bff/tests/test_token_cookies.py diff --git a/bff/.env.example b/bff/.env.example index 0cadf18c..2e3ac16b 100644 --- a/bff/.env.example +++ b/bff/.env.example @@ -5,6 +5,9 @@ PORT=5022 # Flask secret, used to sign session cookie FLASK_SECRET_KEY="replace-me" +# Base64url-encoded 32-byte key used to encrypt token cookies +TOKEN_COOKIE_ENCRYPTION_KEY="replace-with-32-byte-base64url-key" + # OAuth Client and Secret, both are sensitive data OAUTH_CLIENT_ID="replace-me" OAUTH_CLIENT_SECRET="replace-me" diff --git a/bff/README.md b/bff/README.md index c5796df7..f903907c 100644 --- a/bff/README.md +++ b/bff/README.md @@ -20,6 +20,7 @@ For more information on the BFF architecture, see: - Template: `.env.example` - Required values (validated at startup): - `FLASK_SECRET_KEY` +- `TOKEN_COOKIE_ENCRYPTION_KEY` (base64url key that decodes to 32 bytes) - `BACKEND_ENDPOINT` - `CORS_ALLOWED_ORIGIN` - `OAUTH_CLIENT_ID` @@ -41,6 +42,14 @@ For more information on the BFF architecture, see: - `BACKEND_CONNECT_TIMEOUT_SECONDS` (default: `3`) - `BACKEND_READ_TIMEOUT_SECONDS` (default: `30`) +Generate a random `TOKEN_COOKIE_ENCRYPTION_KEY`: +```bash +python - <<'PY' +import base64, secrets +print(base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()) +PY +``` + **Run Locally (Flask / PyCharm)** 1. Ensure `.env` exists in the repo root. 2. Install dependencies. @@ -74,9 +83,15 @@ docker-compose up --build - Docker uses `PORT` from `.env` for the exposed port. **Security Notes** -- Tokens, PKCE verifier, and OAuth state are stored in Flask's signed session cookie. -- Flask signs the cookie to prevent tampering, but cookie contents are not encrypted by default. -- This service does not currently configure Flask-Session/Redis-backed server-side session storage. +- OAuth tokens are stored in encrypted HttpOnly cookies derived from `SESSION_COOKIE_NAME`: + - `_at` (access token) + - `_rt` (refresh token) + - `_it` (id token) + - `_meta` (remaining token payload) +- PKCE verifier and OAuth state remain in Flask's signed session cookie. +- If any encrypted token cookie would exceed the 4 KB browser cookie limit, callback login fails and redirects with `?error=auth_cookie_too_large`. +- Existing signed-session token payloads are not migrated. Users with pre-change sessions must log in again after rollout. +- This service does not configure server-side session storage. - Keep `SESSION_COOKIE_SECURE=True` and `SESSION_COOKIE_SAMESITE=Strict` in production. **Notes / Common Pitfalls** diff --git a/bff/bff_app/__init__.py b/bff/bff_app/__init__.py index 95ffe4ce..0dcb40a2 100644 --- a/bff/bff_app/__init__.py +++ b/bff/bff_app/__init__.py @@ -46,10 +46,10 @@ def create_app(settings: BffSettings) -> Flask: { "type": "apiKey", "in": "cookie", - "name": "SESSION_COOKIE_NAME", + "name": f"{settings.session_cookie_name}_at", "description": ( - "Signed session cookie name is configured at runtime via " - "SESSION_COOKIE_NAME in the app environment." + "Authentication uses encrypted token cookies with suffixes " + "(_at, _rt, _it, _meta) derived from SESSION_COOKIE_NAME." ), }, ) diff --git a/bff/bff_app/openapi/generate.py b/bff/bff_app/openapi/generate.py index b5cf2669..8bbfcdcd 100644 --- a/bff/bff_app/openapi/generate.py +++ b/bff/bff_app/openapi/generate.py @@ -15,6 +15,7 @@ def _spec_settings() -> BffSettings: """Build deterministic settings for spec export.""" return BffSettings( flask_secret_key="openapi-generator", + token_cookie_encryption_key=b"0123456789abcdef0123456789abcdef", session_cookie_name="SESSION_COOKIE_NAME", session_cookie_path="/", session_cookie_httponly=True, diff --git a/bff/bff_app/openapi/openapi.yaml b/bff/bff_app/openapi/openapi.yaml index fbaa42cc..35742f4c 100644 --- a/bff/bff_app/openapi/openapi.yaml +++ b/bff/bff_app/openapi/openapi.yaml @@ -28,8 +28,8 @@ paths: '302': description: Redirects to frontend after successful or failed state check. summary: OAuth callback - description: Exchanges the authorization code for tokens and stores them in - the session. + description: Exchanges the authorization code for tokens and stores encrypted + token cookies. tags: - auth /proxy/api/auth/logout: @@ -341,8 +341,8 @@ components: sessionCookie: type: apiKey in: cookie - name: SESSION_COOKIE_NAME - description: Signed session cookie name is configured at runtime via SESSION_COOKIE_NAME - in the app environment. + name: SESSION_COOKIE_NAME_at + description: Authentication uses encrypted token cookies with suffixes (_at, + _rt, _it, _meta) derived from SESSION_COOKIE_NAME. servers: - url: / diff --git a/bff/bff_app/routes/auth.py b/bff/bff_app/routes/auth.py index 59a5ce4d..495d7856 100644 --- a/bff/bff_app/routes/auth.py +++ b/bff/bff_app/routes/auth.py @@ -2,18 +2,23 @@ from __future__ import annotations -import requests import secrets +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import requests from authlib.integrations.requests_client import OAuth2Session from flask import current_app, jsonify, redirect, request, session from flask_smorest import Blueprint from bff_app.services.auth import ( + clear_session_token, get_session_token, get_settings, + has_session_token_cookie, refresh_access_token, store_session_token, ) +from bff_app.services.token_cookies import TokenCookieTooLargeError auth_bp = Blueprint( "auth", @@ -23,6 +28,33 @@ ) +def _build_redirect_with_error(frontend_redirect: str, error_code: str) -> str: + """Append deterministic error query parameter to the frontend redirect URL.""" + parsed = urlsplit(frontend_redirect) + query_items = [ + (key, value) + for key, value in parse_qsl(parsed.query, keep_blank_values=True) + if key != "error" + ] + query_items.append(("error", error_code)) + + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(query_items), + parsed.fragment, + ) + ) + + +def _clear_auth_state(response: object) -> None: + """Clear both Flask session state and encrypted auth token cookies.""" + session.clear() + clear_session_token(response) + + @auth_bp.route("/login", methods=["GET"]) @auth_bp.doc( summary="Start login flow", @@ -82,7 +114,7 @@ def login(): @auth_bp.route("/callback", methods=["GET"]) @auth_bp.doc( summary="OAuth callback", - description="Exchanges the authorization code for tokens and stores them in the session.", + description="Exchanges the authorization code for tokens and stores encrypted token cookies.", responses={ "302": { "description": "Redirects to frontend after successful or failed state check." @@ -96,8 +128,8 @@ def login_cb(): ``state`` and ``code`` as returned by the OAuth provider. Side effects: - Validates CSRF ``state`` and writes token payload to - ``session["token"]`` on success. + Validates CSRF ``state`` and writes token payload to encrypted + HttpOnly cookies on success. :returns: Redirect response to the configured frontend URL. @@ -113,18 +145,21 @@ def login_cb(): if not session_state or not request_state: current_app.logger.warning("Missing OAuth state during callback") - session.clear() - return redirect(settings.frontend_redirect, code=302) + response = redirect(settings.frontend_redirect, code=302) + _clear_auth_state(response) + return response if request_state != session_state: current_app.logger.warning("OAuth callback state mismatch") - session.clear() - return redirect(settings.frontend_redirect, code=302) + response = redirect(settings.frontend_redirect, code=302) + _clear_auth_state(response) + return response if not authorization_code or not code_verifier: current_app.logger.warning( "Missing authorization code or PKCE verifier during callback" ) - session.clear() - return redirect(settings.frontend_redirect, code=302) + response = redirect(settings.frontend_redirect, code=302) + _clear_auth_state(response) + return response current_app.logger.debug("OAuth callback state validated") current_app.logger.debug("Performing OAuth code exchange") @@ -148,13 +183,34 @@ def login_cb(): ) except Exception as exc: current_app.logger.warning("OAuth token exchange failed: %s", exc) - session.clear() - return redirect(settings.frontend_redirect, code=302) + response = redirect(settings.frontend_redirect, code=302) + _clear_auth_state(response) + return response + + response = redirect(settings.frontend_redirect, code=302) + try: + store_session_token(response, token) + except TokenCookieTooLargeError as exc: + current_app.logger.warning( + "OAuth callback token cookie %s exceeds browser size budget (%s bytes)", + exc.cookie_name, + exc.cookie_size_bytes, + ) + error_redirect = _build_redirect_with_error( + settings.frontend_redirect, + "auth_cookie_too_large", + ) + response = redirect(error_redirect, code=302) + _clear_auth_state(response) + return response + except ValueError as exc: + current_app.logger.warning("OAuth token payload is invalid: %s", exc) + _clear_auth_state(response) + return response - store_session_token(token) session.pop("cv", None) session.pop("state", None) - return redirect(settings.frontend_redirect, code=302) + return response @auth_bp.route("/logout", methods=["GET"]) @@ -208,10 +264,13 @@ def logout(): except requests.exceptions.RequestException as exc: current_app.logger.warning("Logout request failed: %s", exc) else: - current_app.logger.debug("No id_token in session; skipping upstream logout call") + current_app.logger.debug( + "No id_token in token cookies; skipping upstream logout call" + ) - session.clear() - return jsonify({"message": "logout successful"}) + response = jsonify({"message": "logout successful"}) + _clear_auth_state(response) + return response @auth_bp.route("/userinfo", methods=["GET"]) @@ -267,20 +326,26 @@ def auth_userinfo(): """Proxy the OAuth userinfo endpoint for the authenticated session. Requires: - ``session["token"]["access_token"]`` to be present. + ``access_token`` to be present in encrypted auth cookies. :returns: JSON object returned by the upstream userinfo endpoint. - :rtype: dict + :rtype: flask.Response """ current_app.logger.debug("Handling /proxy/api/auth/userinfo") settings = get_settings() token = get_session_token() + has_token_cookie = has_session_token_cookie() + access_token = token.get("access_token") if isinstance(token, dict) else None if not access_token: + response = jsonify({"message": "Unauthorized"}) + response.status_code = 401 session.clear() - return jsonify({"message": "Unauthorized"}), 401 + if has_token_cookie: + clear_session_token(response) + return response try: userinfo = requests.get( @@ -296,8 +361,10 @@ def auth_userinfo(): return jsonify({"message": "Upstream connection error"}), 502 if userinfo.status_code in {401, 403}: - session.clear() - return jsonify({"message": "Unauthorized"}), userinfo.status_code + response = jsonify({"message": "Unauthorized"}) + response.status_code = userinfo.status_code + _clear_auth_state(response) + return response if userinfo.status_code != 200: current_app.logger.warning( @@ -340,7 +407,7 @@ def check_session(): 1. Call the userinfo endpoint with the stored access token. 2. If token is invalid, attempt refresh via refresh token. 3. Re-check userinfo after successful refresh. - 4. Clear session when no valid auth state remains. + 4. Clear auth state when no valid auth state remains. :returns: JSON object containing ``{"session": }``. @@ -349,7 +416,9 @@ def check_session(): current_app.logger.debug("Handling /proxy/api/auth/session") settings = get_settings() + has_token_cookie = has_session_token_cookie() is_valid_session = False + refreshed_token: dict[str, object] | None = None token = get_session_token() if token and "access_token" in token: @@ -362,22 +431,39 @@ def check_session(): ), ) is_valid_session = userinfo.status_code == 200 - if not is_valid_session and refresh_access_token(): - refreshed_token = get_session_token() - if not refreshed_token or "access_token" not in refreshed_token: - session.clear() - return jsonify({"session": False}) - userinfo = requests.get( - settings.oauth_endpoint_userinfo, - headers={"Authorization": f"Bearer {refreshed_token['access_token']}"}, - timeout=( - settings.backend_connect_timeout_seconds, - settings.backend_read_timeout_seconds, - ), + if not is_valid_session: + refreshed_token = refresh_access_token() + if refreshed_token and "access_token" in refreshed_token: + userinfo = requests.get( + settings.oauth_endpoint_userinfo, + headers={ + "Authorization": f"Bearer {refreshed_token['access_token']}" + }, + timeout=( + settings.backend_connect_timeout_seconds, + settings.backend_read_timeout_seconds, + ), + ) + is_valid_session = userinfo.status_code == 200 + + response = jsonify({"session": is_valid_session}) + + if is_valid_session and refreshed_token: + try: + store_session_token(response, refreshed_token) + except TokenCookieTooLargeError as exc: + current_app.logger.warning( + "Refreshed token cookie %s exceeds browser size budget (%s bytes)", + exc.cookie_name, + exc.cookie_size_bytes, ) - is_valid_session = userinfo.status_code == 200 + response = jsonify({"session": False}) + _clear_auth_state(response) + return response if not is_valid_session: session.clear() + if has_token_cookie: + clear_session_token(response) - return jsonify({"session": is_valid_session}) + return response diff --git a/bff/bff_app/routes/proxy.py b/bff/bff_app/routes/proxy.py index 46863c25..1416010a 100644 --- a/bff/bff_app/routes/proxy.py +++ b/bff/bff_app/routes/proxy.py @@ -6,7 +6,15 @@ from flask import Response, current_app, request from flask_smorest import Blueprint -from bff_app.services.auth import get_session_token, get_settings, refresh_access_token +from bff_app.services.auth import ( + clear_session_token, + get_session_token, + get_settings, + has_session_token_cookie, + refresh_access_token, + store_session_token, +) +from bff_app.services.token_cookies import TokenCookieTooLargeError proxy_bp = Blueprint( "proxy", @@ -96,7 +104,7 @@ def proxy_request(rest_of_url: str): Behavior: - Handles CORS preflight by returning ``204`` on ``OPTIONS``. - Removes sensitive/hop-by-hop request headers before forwarding upstream. - - Injects bearer access token from session when available. + - Injects bearer access token from encrypted auth cookies when available. - Retries once after token refresh when upstream returns ``401`` with ``invalid_token``. - Drops hop-by-hop and duplicate CORS headers from upstream response. @@ -107,12 +115,18 @@ def proxy_request(rest_of_url: str): headers = _build_upstream_headers() payload = request.get_data() + has_token_cookie = has_session_token_cookie() + should_clear_token_cookies = False + refreshed_token: dict[str, object] | None = None + session_token = get_session_token() if session_token and "access_token" in session_token: headers["Authorization"] = f"Bearer {session_token['access_token']}" else: + if has_token_cookie: + should_clear_token_cookies = True current_app.logger.warning( - "Access token missing in session for proxied request: %s", + "Access token missing in auth cookies for proxied request: %s", rest_of_url, ) @@ -136,14 +150,15 @@ def forward_request(): response = forward_request() except requests.exceptions.RequestException as exc: current_app.logger.warning("REST proxy error for %s: %s", rest_of_url, exc) - return Response("Upstream connection error", status=502) + failed_response = Response("Upstream connection error", status=502) + if should_clear_token_cookies: + clear_session_token(failed_response) + return failed_response www_authenticate = response.headers.get("www-authenticate", "") if response.status_code == 401 and "invalid_token" in www_authenticate: - if refresh_access_token(): - refreshed_token = get_session_token() - if not refreshed_token or "access_token" not in refreshed_token: - return Response("Upstream connection error", status=502) + refreshed_token = refresh_access_token() + if refreshed_token and "access_token" in refreshed_token: headers["Authorization"] = f"Bearer {refreshed_token['access_token']}" try: response = forward_request() @@ -153,7 +168,11 @@ def forward_request(): rest_of_url, exc, ) - return Response("Upstream connection error", status=502) + failed_response = Response("Upstream connection error", status=502) + clear_session_token(failed_response) + return failed_response + else: + should_clear_token_cookies = has_token_cookie excluded_headers = [ "transfer-encoding", @@ -165,4 +184,22 @@ def forward_request(): if k.lower() not in excluded_headers ] - return Response(response.content, response.status_code, filtered_headers) + proxied_response = Response(response.content, response.status_code, filtered_headers) + + if refreshed_token: + try: + store_session_token(proxied_response, refreshed_token) + except TokenCookieTooLargeError as exc: + current_app.logger.warning( + "Refreshed token cookie %s exceeds browser size budget (%s bytes)", + exc.cookie_name, + exc.cookie_size_bytes, + ) + unauthorized_response = Response("Unauthorized", status=401) + clear_session_token(unauthorized_response) + return unauthorized_response + + if should_clear_token_cookies: + clear_session_token(proxied_response) + + return proxied_response diff --git a/bff/bff_app/services/auth.py b/bff/bff_app/services/auth.py index 9bcd37fb..4fabacac 100644 --- a/bff/bff_app/services/auth.py +++ b/bff/bff_app/services/auth.py @@ -5,8 +5,14 @@ from typing import Any, Mapping import requests -from flask import current_app, session - +from flask import current_app, request + +from bff_app.services.token_cookies import ( + clear_token_cookies, + has_any_token_cookie, + load_token_from_cookies, + set_token_cookies, +) from bff_app.settings import BffSettings @@ -19,43 +25,44 @@ def get_settings() -> BffSettings: return current_app.extensions["bff_settings"] -def store_session_token(token: Mapping[str, Any]) -> None: - """Store OAuth token payload in the signed Flask session cookie.""" - session["token"] = dict(token) +def store_session_token(response: Any, token: Mapping[str, Any]) -> None: + """Store OAuth token payload in encrypted HttpOnly cookies.""" + settings = get_settings() + set_token_cookies(response, token, settings) -def get_session_token() -> dict[str, Any] | None: - """Return OAuth token payload from the signed Flask session cookie.""" - raw_token = session.get("token") - if isinstance(raw_token, Mapping): - return dict(raw_token) +def clear_session_token(response: Any) -> None: + """Clear all encrypted token cookies from the browser.""" + settings = get_settings() + clear_token_cookies(response, settings) + + +def has_session_token_cookie() -> bool: + """Return whether at least one auth token cookie is present.""" + settings = get_settings() + return has_any_token_cookie(request.cookies, settings) - if raw_token is not None: - current_app.logger.warning( - "Session token payload has unexpected type %s", - type(raw_token).__name__, - ) - session.pop("token", None) - return None +def get_session_token() -> dict[str, Any] | None: + """Return OAuth token payload from encrypted auth cookies.""" + settings = get_settings() + return load_token_from_cookies(request.cookies, settings) -def refresh_access_token() -> bool: - """Refresh the access token using the current session refresh token. - The function updates ``session["token"]`` with the token payload returned - by the OAuth token endpoint. +def refresh_access_token() -> dict[str, Any] | None: + """Refresh the access token using the current refresh token. :returns: - ``True`` when the token refresh succeeds, otherwise ``False``. - :rtype: bool + Merged token payload when refresh succeeds, otherwise ``None``. + :rtype: dict[str, Any] | None """ settings = get_settings() - session_token = get_session_token() or {} - refresh_token = session_token.get("refresh_token") - if not refresh_token: - current_app.logger.warning("Refresh token is missing from session") - return False + existing_token = get_session_token() or {} + refresh_token = existing_token.get("refresh_token") + if not isinstance(refresh_token, str) or not refresh_token: + current_app.logger.warning("Refresh token is missing from token cookies") + return None try: response = requests.post( @@ -73,14 +80,23 @@ def refresh_access_token() -> bool: ) except requests.exceptions.RequestException as exc: current_app.logger.warning("Refresh token request failed: %s", exc) - return False + return None if response.status_code != 200: current_app.logger.warning( "Refresh token request rejected with status %s", response.status_code, ) - return False + return None + + refreshed_payload = response.json() + if not isinstance(refreshed_payload, Mapping): + current_app.logger.warning( + "Refresh token response has unexpected type %s", + type(refreshed_payload).__name__, + ) + return None - store_session_token(response.json()) - return True + merged_token = dict(existing_token) + merged_token.update(dict(refreshed_payload)) + return merged_token diff --git a/bff/bff_app/services/token_cookies.py b/bff/bff_app/services/token_cookies.py new file mode 100644 index 00000000..db2a4fd8 --- /dev/null +++ b/bff/bff_app/services/token_cookies.py @@ -0,0 +1,283 @@ +"""Encrypted auth token cookie helpers.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import os +import warnings +from typing import Any, Mapping + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from flask import current_app +from werkzeug.datastructures import MultiDict +from werkzeug.http import dump_cookie + +from bff_app.settings import BffSettings + +COOKIE_VERSION = 1 +COOKIE_NONCE_BYTES = 12 +MAX_SET_COOKIE_BYTES = 4096 + +TOKEN_COOKIE_SUFFIXES = { + "access_token": "at", + "refresh_token": "rt", + "id_token": "it", + "meta": "meta", +} + +REQUIRED_TOKEN_FIELDS: tuple[str, ...] = ( + "access_token", + "refresh_token", + "id_token", +) + + +class TokenCookieTooLargeError(ValueError): + """Raised when a token cookie exceeds the browser size budget.""" + + def __init__(self, cookie_name: str, cookie_size_bytes: int) -> None: + super().__init__( + f"Cookie {cookie_name!r} is {cookie_size_bytes} bytes; max is " + f"{MAX_SET_COOKIE_BYTES} bytes" + ) + self.cookie_name = cookie_name + self.cookie_size_bytes = cookie_size_bytes + + +def token_cookie_names(settings: BffSettings) -> dict[str, str]: + """Return concrete token-cookie names derived from session cookie prefix.""" + prefix = settings.session_cookie_name + return { + field: f"{prefix}_{suffix}" + for field, suffix in TOKEN_COOKIE_SUFFIXES.items() + } + + +def has_any_token_cookie( + cookies: Mapping[str, str] | MultiDict[str, str], + settings: BffSettings, +) -> bool: + """Return whether any encrypted token cookie is present in the request.""" + names = token_cookie_names(settings) + return any(name in cookies for name in names.values()) + + +def load_token_from_cookies( + cookies: Mapping[str, str] | MultiDict[str, str], + settings: BffSettings, +) -> dict[str, Any] | None: + """Load and decrypt full OAuth token payload from request cookies.""" + names = token_cookie_names(settings) + encrypted_parts = { + field: cookies.get(name) + for field, name in names.items() + } + + present_parts = sum(value is not None for value in encrypted_parts.values()) + if present_parts == 0: + return None + + if present_parts != len(encrypted_parts): + _log_warning("Token cookie payload is incomplete; treating as invalid") + return None + + key = settings.token_cookie_encryption_key + decrypted_token_fields: dict[str, str] = {} + try: + for field in REQUIRED_TOKEN_FIELDS: + cookie_name = names[field] + encrypted_value = encrypted_parts[field] + if not isinstance(encrypted_value, str): + _log_warning("Token cookie %s has unexpected type", cookie_name) + return None + decrypted_token_fields[field] = _decrypt_component( + encrypted_value, + key, + cookie_name, + ) + + meta_cookie_name = names["meta"] + encrypted_meta = encrypted_parts["meta"] + if not isinstance(encrypted_meta, str): + _log_warning("Token cookie %s has unexpected type", meta_cookie_name) + return None + decrypted_meta = _decrypt_component(encrypted_meta, key, meta_cookie_name) + except (InvalidTag, ValueError, binascii.Error, UnicodeDecodeError) as exc: + _log_warning("Failed to decrypt token cookies: %s", exc) + return None + + try: + raw_meta = json.loads(decrypted_meta) + except json.JSONDecodeError as exc: + _log_warning("Failed to decode token cookie metadata JSON: %s", exc) + return None + + if not isinstance(raw_meta, dict): + _log_warning( + "Token cookie metadata JSON must decode to object, got %s", + type(raw_meta).__name__, + ) + return None + + for required_field in REQUIRED_TOKEN_FIELDS: + if required_field in raw_meta: + _log_warning( + "Token cookie metadata contains reserved field %s", + required_field, + ) + return None + + token: dict[str, Any] = dict(raw_meta) + token.update(decrypted_token_fields) + return token + + +def set_token_cookies( + response: Any, + token: Mapping[str, Any], + settings: BffSettings, +) -> None: + """Encrypt and write OAuth token payload to multiple HttpOnly cookies.""" + names = token_cookie_names(settings) + token_payload = dict(token) + + missing_fields = [ + field + for field in REQUIRED_TOKEN_FIELDS + if field not in token_payload or not isinstance(token_payload[field], str) + ] + if missing_fields: + missing_csv = ", ".join(missing_fields) + raise ValueError( + "Token payload must contain string values for required fields: " + f"{missing_csv}" + ) + + key = settings.token_cookie_encryption_key + encrypted_values = { + "access_token": _encrypt_component( + token_payload["access_token"], + key, + names["access_token"], + ), + "refresh_token": _encrypt_component( + token_payload["refresh_token"], + key, + names["refresh_token"], + ), + "id_token": _encrypt_component( + token_payload["id_token"], + key, + names["id_token"], + ), + } + + metadata_payload = { + metadata_key: value + for metadata_key, value in token_payload.items() + if metadata_key not in REQUIRED_TOKEN_FIELDS + } + encrypted_values["meta"] = _encrypt_component( + json.dumps(metadata_payload, separators=(",", ":"), sort_keys=True), + key, + names["meta"], + ) + + for field, encrypted_value in encrypted_values.items(): + _validate_set_cookie_size( + cookie_name=names[field], + cookie_value=encrypted_value, + settings=settings, + ) + + for field, encrypted_value in encrypted_values.items(): + response.set_cookie( + names[field], + encrypted_value, + path=settings.session_cookie_path, + secure=settings.session_cookie_secure, + httponly=settings.session_cookie_httponly, + samesite=settings.session_cookie_samesite, + ) + + +def clear_token_cookies(response: Any, settings: BffSettings) -> None: + """Clear all token cookies from the client.""" + for cookie_name in token_cookie_names(settings).values(): + response.delete_cookie( + cookie_name, + path=settings.session_cookie_path, + secure=settings.session_cookie_secure, + httponly=settings.session_cookie_httponly, + samesite=settings.session_cookie_samesite, + ) + + +def _encrypt_component(plaintext: str, key: bytes, cookie_name: str) -> str: + nonce = os.urandom(COOKIE_NONCE_BYTES) + ciphertext = AESGCM(key).encrypt( + nonce, + plaintext.encode("utf-8"), + cookie_name.encode("utf-8"), + ) + payload = bytes([COOKIE_VERSION]) + nonce + ciphertext + return _urlsafe_b64encode(payload) + + +def _decrypt_component(encoded_payload: str, key: bytes, cookie_name: str) -> str: + raw_payload = _urlsafe_b64decode(encoded_payload) + min_payload_size = 1 + COOKIE_NONCE_BYTES + 16 + if len(raw_payload) < min_payload_size: + raise ValueError("Encrypted cookie payload is truncated") + + payload_version = raw_payload[0] + if payload_version != COOKIE_VERSION: + raise ValueError(f"Unsupported cookie payload version: {payload_version}") + + nonce = raw_payload[1 : 1 + COOKIE_NONCE_BYTES] + ciphertext = raw_payload[1 + COOKIE_NONCE_BYTES :] + plaintext = AESGCM(key).decrypt( + nonce, + ciphertext, + cookie_name.encode("utf-8"), + ) + return plaintext.decode("utf-8") + + +def _urlsafe_b64encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _urlsafe_b64decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) + + +def _validate_set_cookie_size( + cookie_name: str, + cookie_value: str, + settings: BffSettings, +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + cookie_header = dump_cookie( + key=cookie_name, + value=cookie_value, + path=settings.session_cookie_path, + secure=settings.session_cookie_secure, + httponly=settings.session_cookie_httponly, + samesite=settings.session_cookie_samesite, + ) + cookie_size_bytes = len(cookie_header.encode("utf-8")) + if cookie_size_bytes > MAX_SET_COOKIE_BYTES: + raise TokenCookieTooLargeError(cookie_name, cookie_size_bytes) + + +def _log_warning(message: str, *args: Any) -> None: + try: + current_app.logger.warning(message, *args) + except RuntimeError: + return diff --git a/bff/bff_app/settings.py b/bff/bff_app/settings.py index 118ac0b9..e7a63508 100644 --- a/bff/bff_app/settings.py +++ b/bff/bff_app/settings.py @@ -2,6 +2,8 @@ from __future__ import annotations +import base64 +import binascii import os from dataclasses import dataclass from typing import Mapping @@ -53,11 +55,36 @@ def _env_positive_float(name: str, default: float) -> float: return parsed +def _env_base64url_32_bytes(name: str) -> bytes: + """Parse a URL-safe base64 encoded 32-byte key from env.""" + value = os.getenv(name) + if value is None or not value.strip(): + raise SettingsValidationError(f"{name} must be set to a base64url-encoded key") + + normalized = value.strip() + padding = "=" * (-len(normalized) % 4) + try: + decoded = base64.urlsafe_b64decode(normalized + padding) + except (binascii.Error, ValueError) as exc: + raise SettingsValidationError( + f"{name} must be a valid base64url-encoded value" + ) from exc + + if len(decoded) != 32: + raise SettingsValidationError( + f"{name} must decode to exactly 32 bytes; got {len(decoded)} bytes" + ) + + return decoded + + @dataclass(frozen=True) class BffSettings: """Immutable runtime settings used by endpoint handlers. :ivar flask_secret_key: Secret key used to sign the Flask session cookie. + :ivar token_cookie_encryption_key: + Base64url-decoded 32-byte key used to encrypt token cookies. :ivar session_cookie_name: Name of the session cookie. :ivar session_cookie_path: Path scope of the session cookie. :ivar session_cookie_httponly: Whether JavaScript access to the cookie is disabled. @@ -80,6 +107,7 @@ class BffSettings: Read timeout in seconds for backend proxy requests. """ flask_secret_key: str + token_cookie_encryption_key: bytes session_cookie_name: str session_cookie_path: str session_cookie_httponly: bool @@ -102,6 +130,7 @@ class BffSettings: REQUIRED_ENV_VARS: tuple[str, ...] = ( "FLASK_SECRET_KEY", + "TOKEN_COOKIE_ENCRYPTION_KEY", "SESSION_COOKIE_NAME", "SESSION_COOKIE_PATH", "SESSION_COOKIE_HTTPONLY", @@ -153,6 +182,9 @@ def load_settings_from_env() -> BffSettings: return BffSettings( flask_secret_key=raw_env["FLASK_SECRET_KEY"], + token_cookie_encryption_key=_env_base64url_32_bytes( + "TOKEN_COOKIE_ENCRYPTION_KEY" + ), session_cookie_name=raw_env["SESSION_COOKIE_NAME"], session_cookie_path=os.getenv("SESSION_COOKIE_PATH", "/"), session_cookie_httponly=_env_bool("SESSION_COOKIE_HTTPONLY", True), diff --git a/bff/pyproject.toml b/bff/pyproject.toml index 75c169e7..6de8c3cf 100644 --- a/bff/pyproject.toml +++ b/bff/pyproject.toml @@ -5,6 +5,7 @@ description = "Flask backend-for-frontend" requires-python = ">=3.12,<3.13" dependencies = [ "authlib", + "cryptography", "flask~=3.1.2", "flask-cors", "flask-session", diff --git a/bff/tests/conftest.py b/bff/tests/conftest.py index b9c9fa4b..075c0169 100644 --- a/bff/tests/conftest.py +++ b/bff/tests/conftest.py @@ -1,4 +1,5 @@ import sys +from http.cookies import SimpleCookie from pathlib import Path import pytest @@ -16,6 +17,10 @@ def app(monkeypatch): """ # Core Flask config monkeypatch.setenv("FLASK_SECRET_KEY", "test-secret") + monkeypatch.setenv( + "TOKEN_COOKIE_ENCRYPTION_KEY", + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + ) monkeypatch.setenv("SESSION_COOKIE_NAME", "test-session") monkeypatch.setenv("SESSION_COOKIE_PATH", "/") monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") @@ -48,3 +53,46 @@ def app(monkeypatch): @pytest.fixture() def client(app): return app.test_client() + + +@pytest.fixture() +def build_token_payload(): + def _build_token_payload(**overrides): + payload = { + "access_token": "access-token", + "refresh_token": "refresh-token", + "id_token": "id-token", + "expires_in": 300, + "refresh_expires_in": 1800, + "token_type": "Bearer", + "scope": "openid profile", + "expires_at": 1773046864, + } + payload.update(overrides) + return payload + + return _build_token_payload + + +@pytest.fixture() +def set_auth_cookies(app): + from bff_app.services.token_cookies import set_token_cookies + + settings = app.extensions["bff_settings"] + + def _set_auth_cookies(client, token_payload): + response = app.response_class() + set_token_cookies(response, token_payload, settings) + + parsed = SimpleCookie() + for set_cookie_header in response.headers.getlist("Set-Cookie"): + parsed.load(set_cookie_header) + + for morsel in parsed.values(): + client.set_cookie( + key=morsel.key, + value=morsel.value, + path=morsel["path"] or "/", + ) + + return _set_auth_cookies diff --git a/bff/tests/test_auth_callback.py b/bff/tests/test_auth_callback.py index e2e75f32..04a35f20 100644 --- a/bff/tests/test_auth_callback.py +++ b/bff/tests/test_auth_callback.py @@ -7,7 +7,11 @@ def _fake_oauth_session(state="state-123", token=None): """ Build a fake OAuth2 session with deterministic responses. """ - token = token or {"access_token": "access-token", "id_token": "id-token"} + token = token or { + "access_token": "access-token", + "refresh_token": "refresh-token", + "id_token": "id-token", + } fake = MagicMock() fake.create_authorization_url.return_value = ("http://auth.test/login", state) fake.fetch_token.return_value = token @@ -16,7 +20,14 @@ def _fake_oauth_session(state="state-123", token=None): def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): # Mock the token exchange so we don't call the real auth server. - fake_oauth = _fake_oauth_session(token={"access_token": "tok", "id_token": "id"}) + fake_oauth = _fake_oauth_session( + token={ + "access_token": "tok", + "refresh_token": "rtok", + "id_token": "id", + "expires_in": 300, + } + ) monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) # Pre-populate session with state and PKCE verifier to match the callback request. @@ -28,18 +39,26 @@ def test_login_callback_exchanges_code_and_redirects(client, monkeypatch): assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=") for header in set_cookie_headers) + assert any(header.startswith("test-session_rt=") for header in set_cookie_headers) + assert any(header.startswith("test-session_it=") for header in set_cookie_headers) + assert any(header.startswith("test-session_meta=") for header in set_cookie_headers) - # Token should be saved to the session after the code exchange. with client.session_transaction() as sess: - assert sess["token"]["access_token"] == "tok" + assert "token" not in sess assert "state" not in sess assert "cv" not in sess -def test_login_callback_state_mismatch_redirects(client): +def test_login_callback_state_mismatch_redirects( + client, + set_auth_cookies, + build_token_payload, +): # State mismatch short-circuits without token exchange. + set_auth_cookies(client, build_token_payload()) with client.session_transaction() as sess: - sess["token"] = {"access_token": "old"} sess["state"] = "state-123" sess["cv"] = "cv-hex" @@ -47,26 +66,38 @@ def test_login_callback_state_mismatch_redirects(client): assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) with client.session_transaction() as sess: assert len(sess.keys()) == 0 -def test_login_callback_missing_state_redirects_and_clears_session(client): +def test_login_callback_missing_state_redirects_and_clears_session( + client, + set_auth_cookies, + build_token_payload, +): + set_auth_cookies(client, build_token_payload()) with client.session_transaction() as sess: - sess["token"] = {"access_token": "old"} sess["cv"] = "cv-hex" res = client.get("/proxy/api/auth/callback?code=abc") assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) with client.session_transaction() as sess: assert len(sess.keys()) == 0 -def test_login_callback_missing_code_redirects_and_clears_session(client): +def test_login_callback_missing_code_redirects_and_clears_session( + client, + set_auth_cookies, + build_token_payload, +): + set_auth_cookies(client, build_token_payload()) with client.session_transaction() as sess: - sess["token"] = {"access_token": "old"} sess["state"] = "state-123" sess["cv"] = "cv-hex" @@ -74,17 +105,24 @@ def test_login_callback_missing_code_redirects_and_clears_session(client): assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) with client.session_transaction() as sess: assert len(sess.keys()) == 0 -def test_login_callback_token_exchange_error_redirects_without_500(client, monkeypatch): +def test_login_callback_token_exchange_error_redirects_without_500( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): fake_oauth = _fake_oauth_session() fake_oauth.fetch_token.side_effect = RuntimeError("boom") monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) + set_auth_cookies(client, build_token_payload()) with client.session_transaction() as sess: - sess["token"] = {"access_token": "old"} sess["state"] = "state-123" sess["cv"] = "cv-hex" @@ -92,5 +130,30 @@ def test_login_callback_token_exchange_error_redirects_without_500(client, monke assert res.status_code == 302 assert res.headers["Location"] == "http://frontend.test" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) with client.session_transaction() as sess: assert len(sess.keys()) == 0 + + +def test_login_callback_cookie_too_large_redirects_with_error(client, monkeypatch): + large_access_token = "a" * 7000 + fake_oauth = _fake_oauth_session( + token={ + "access_token": large_access_token, + "refresh_token": "refresh-token", + "id_token": "id-token", + } + ) + monkeypatch.setattr(auth_routes, "OAuth2Session", lambda *args, **kwargs: fake_oauth) + + with client.session_transaction() as sess: + sess["state"] = "state-123" + sess["cv"] = "cv-hex" + + res = client.get("/proxy/api/auth/callback?state=state-123&code=abc") + + assert res.status_code == 302 + assert res.headers["Location"] == "http://frontend.test?error=auth_cookie_too_large" + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) diff --git a/bff/tests/test_auth_logout.py b/bff/tests/test_auth_logout.py index fa630b67..7cbd7aa7 100644 --- a/bff/tests/test_auth_logout.py +++ b/bff/tests/test_auth_logout.py @@ -6,13 +6,17 @@ from bff_app.routes import auth as auth_routes -def test_logout_revokes_tokens_and_clears_session(client, monkeypatch): +def test_logout_revokes_tokens_and_clears_session( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): # Mock the auth server logout call. mock_post = MagicMock(return_value=SimpleNamespace(status_code=200)) monkeypatch.setattr(auth_routes.requests, "post", mock_post) - with client.session_transaction() as sess: - sess["token"] = {"id_token": "id-token"} + set_auth_cookies(client, build_token_payload(id_token="id-token")) res = client.get("/proxy/api/auth/logout") @@ -44,12 +48,16 @@ def test_logout_without_token_still_clears_session(client, monkeypatch): assert len(sess.keys()) == 0 -def test_logout_handles_upstream_timeout_and_still_clears_session(client, monkeypatch): +def test_logout_handles_upstream_timeout_and_still_clears_session( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): mock_post = MagicMock(side_effect=requests.exceptions.Timeout("timeout")) monkeypatch.setattr(auth_routes.requests, "post", mock_post) - with client.session_transaction() as sess: - sess["token"] = {"id_token": "id-token"} + set_auth_cookies(client, build_token_payload(id_token="id-token")) res = client.get("/proxy/api/auth/logout") diff --git a/bff/tests/test_auth_session.py b/bff/tests/test_auth_session.py index b3d587ff..770b2f6a 100644 --- a/bff/tests/test_auth_session.py +++ b/bff/tests/test_auth_session.py @@ -5,13 +5,17 @@ from bff_app.services import auth as auth_service -def test_session_true_when_userinfo_ok(client, monkeypatch): +def test_session_true_when_userinfo_ok( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): # If userinfo returns 200, the session is considered valid. mock_get = MagicMock(return_value=SimpleNamespace(status_code=200)) monkeypatch.setattr(auth_routes.requests, "get", mock_get) - with client.session_transaction() as sess: - sess["token"] = {"access_token": "access-token"} + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/session") @@ -19,10 +23,16 @@ def test_session_true_when_userinfo_ok(client, monkeypatch): assert res.get_json() == {"session": True} -def test_session_refreshes_and_recovers(client, monkeypatch): +def test_session_refreshes_and_recovers( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): mock_get = MagicMock(side_effect=[ SimpleNamespace(status_code=401), SimpleNamespace(status_code=200), + SimpleNamespace(status_code=200, json=lambda: {"sub": "user-1"}), ]) mock_post = MagicMock(return_value=SimpleNamespace( status_code=200, @@ -34,34 +44,32 @@ def test_session_refreshes_and_recovers(client, monkeypatch): monkeypatch.setattr(auth_routes.requests, "get", mock_get) monkeypatch.setattr(auth_service.requests, "post", mock_post) - with client.session_transaction() as sess: - sess["token"] = { - "access_token": "access-token", - "refresh_token": "refresh-token", - } + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/session") assert res.status_code == 200 assert res.get_json() == {"session": True} assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) - with client.session_transaction() as sess: - assert sess["token"]["access_token"] == "new-access-token" - assert sess["token"]["refresh_token"] == "new-refresh-token" - - -def test_session_false_clears_cookie_on_failure(client, monkeypatch): + res2 = client.get("/proxy/api/auth/userinfo") + assert res2.status_code == 200 + auth_header = mock_get.call_args_list[-1].kwargs["headers"]["Authorization"] + assert auth_header == "Bearer new-access-token" + + +def test_session_false_clears_cookie_on_failure( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): # Non-200 from userinfo causes the session to be cleared. mock_get = MagicMock(return_value=SimpleNamespace(status_code=401)) mock_post = MagicMock(return_value=SimpleNamespace(status_code=400)) monkeypatch.setattr(auth_routes.requests, "get", mock_get) monkeypatch.setattr(auth_service.requests, "post", mock_post) - with client.session_transaction() as sess: - sess["token"] = { - "access_token": "access-token", - "refresh_token": "refresh-token", - } + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/session") @@ -69,5 +77,8 @@ def test_session_false_clears_cookie_on_failure(client, monkeypatch): assert res.get_json() == {"session": False} assert mock_post.call_args.kwargs["timeout"] == (3.0, 30.0) - with client.session_transaction() as sess: - assert "token" not in sess + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_rt=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_it=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_meta=;") for header in set_cookie_headers) diff --git a/bff/tests/test_auth_userinfo.py b/bff/tests/test_auth_userinfo.py index ac19fdad..b6536020 100644 --- a/bff/tests/test_auth_userinfo.py +++ b/bff/tests/test_auth_userinfo.py @@ -6,7 +6,12 @@ from bff_app.routes import auth as auth_routes -def test_userinfo_proxies_to_auth_server(client, monkeypatch): +def test_userinfo_proxies_to_auth_server( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): # Mock userinfo response from auth server. mock_get = MagicMock( return_value=SimpleNamespace( @@ -16,8 +21,7 @@ def test_userinfo_proxies_to_auth_server(client, monkeypatch): ) monkeypatch.setattr(auth_routes.requests, "get", mock_get) - with client.session_transaction() as sess: - sess["token"] = {"access_token": "access-token"} + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/userinfo") @@ -38,12 +42,16 @@ def test_userinfo_missing_session_token_returns_401(client, monkeypatch): mock_get.assert_not_called() -def test_userinfo_handles_upstream_request_errors(client, monkeypatch): +def test_userinfo_handles_upstream_request_errors( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): mock_get = MagicMock(side_effect=requests.exceptions.Timeout("timeout")) monkeypatch.setattr(auth_routes.requests, "get", mock_get) - with client.session_transaction() as sess: - sess["token"] = {"access_token": "access-token"} + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/userinfo") @@ -51,7 +59,12 @@ def test_userinfo_handles_upstream_request_errors(client, monkeypatch): assert res.get_json() == {"message": "Upstream connection error"} -def test_userinfo_clears_session_on_upstream_401(client, monkeypatch): +def test_userinfo_clears_session_on_upstream_401( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): mock_get = MagicMock( return_value=SimpleNamespace( status_code=401, @@ -60,12 +73,14 @@ def test_userinfo_clears_session_on_upstream_401(client, monkeypatch): ) monkeypatch.setattr(auth_routes.requests, "get", mock_get) - with client.session_transaction() as sess: - sess["token"] = {"access_token": "access-token"} + set_auth_cookies(client, build_token_payload()) res = client.get("/proxy/api/auth/userinfo") assert res.status_code == 401 assert res.get_json() == {"message": "Unauthorized"} - with client.session_transaction() as sess: - assert "token" not in sess + set_cookie_headers = res.headers.getlist("Set-Cookie") + assert any(header.startswith("test-session_at=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_rt=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_it=;") for header in set_cookie_headers) + assert any(header.startswith("test-session_meta=;") for header in set_cookie_headers) diff --git a/bff/tests/test_proxy_request.py b/bff/tests/test_proxy_request.py index 5a21a74d..1ed401a0 100644 --- a/bff/tests/test_proxy_request.py +++ b/bff/tests/test_proxy_request.py @@ -5,7 +5,12 @@ from bff_app.services import auth as auth_service -def test_proxy_request_forwards_to_backend(client, monkeypatch): +def test_proxy_request_forwards_to_backend( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): # Mock backend response so we avoid a real HTTP call. backend_response = SimpleNamespace( content=b'{"ok":true}', @@ -19,8 +24,7 @@ def test_proxy_request_forwards_to_backend(client, monkeypatch): mock_request = MagicMock(return_value=backend_response) monkeypatch.setattr(proxy_routes.requests, "request", mock_request) - with client.session_transaction() as sess: - sess["token"] = {"access_token": "access-token"} + set_auth_cookies(client, build_token_payload()) res = client.post( "/proxy/api/request/widgets?limit=5", @@ -59,7 +63,12 @@ def test_proxy_request_options_short_circuits(client): assert res.status_code == 204 -def test_proxy_request_retries_on_invalid_token(client, monkeypatch): +def test_proxy_request_retries_on_invalid_token( + client, + monkeypatch, + set_auth_cookies, + build_token_payload, +): backend_response = SimpleNamespace( content=b'{"ok":true}', status_code=200, @@ -84,11 +93,13 @@ def test_proxy_request_retries_on_invalid_token(client, monkeypatch): )) monkeypatch.setattr(auth_service.requests, "post", mock_post) - with client.session_transaction() as sess: - sess["token"] = { - "access_token": "access-token", - "refresh_token": "refresh-token", - } + set_auth_cookies( + client, + build_token_payload( + access_token="access-token", + refresh_token="refresh-token", + ), + ) res = client.get("/proxy/api/request/widgets") diff --git a/bff/tests/test_settings.py b/bff/tests/test_settings.py index ea1f31ee..99238c97 100644 --- a/bff/tests/test_settings.py +++ b/bff/tests/test_settings.py @@ -5,6 +5,10 @@ def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FLASK_SECRET_KEY", "test-secret") + monkeypatch.setenv( + "TOKEN_COOKIE_ENCRYPTION_KEY", + "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY", + ) monkeypatch.setenv("SESSION_COOKIE_NAME", "test-session") monkeypatch.setenv("SESSION_COOKIE_PATH", "/") monkeypatch.setenv("SESSION_COOKIE_HTTPONLY", "True") @@ -52,6 +56,7 @@ def test_load_settings_from_env_returns_validated_settings( settings = load_settings_from_env() assert settings.flask_secret_key == "test-secret" + assert len(settings.token_cookie_encryption_key) == 32 assert settings.session_cookie_name == "test-session" assert settings.session_cookie_path == "/" assert settings.session_cookie_httponly is True @@ -96,3 +101,16 @@ def test_load_settings_from_env_rejects_invalid_backend_timeouts( match="BACKEND_CONNECT_TIMEOUT_SECONDS must be greater than 0", ): load_settings_from_env() + + +def test_load_settings_from_env_rejects_invalid_cookie_encryption_key( + monkeypatch: pytest.MonkeyPatch, +): + _set_required_env(monkeypatch) + monkeypatch.setenv("TOKEN_COOKIE_ENCRYPTION_KEY", "Zm9v") + + with pytest.raises( + SettingsValidationError, + match="TOKEN_COOKIE_ENCRYPTION_KEY must decode to exactly 32 bytes", + ): + load_settings_from_env() diff --git a/bff/tests/test_token_cookies.py b/bff/tests/test_token_cookies.py new file mode 100644 index 00000000..50ae0c4c --- /dev/null +++ b/bff/tests/test_token_cookies.py @@ -0,0 +1,94 @@ +from http.cookies import SimpleCookie + +import pytest + +from bff_app.services.token_cookies import ( + TokenCookieTooLargeError, + has_any_token_cookie, + load_token_from_cookies, + set_token_cookies, + token_cookie_names, +) + + +def _extract_cookie_map(response) -> dict[str, str]: + parsed = SimpleCookie() + for set_cookie_header in response.headers.getlist("Set-Cookie"): + parsed.load(set_cookie_header) + return {morsel.key: morsel.value for morsel in parsed.values()} + + +def test_token_cookie_roundtrip_preserves_payload(app, build_token_payload): + settings = app.extensions["bff_settings"] + response = app.response_class() + token_payload = build_token_payload( + scope="openid email profile", + expires_at=1773046999, + custom_field="custom-value", + ) + + set_token_cookies(response, token_payload, settings) + cookie_map = _extract_cookie_map(response) + + assert has_any_token_cookie(cookie_map, settings) is True + loaded_payload = load_token_from_cookies(cookie_map, settings) + assert loaded_payload == token_payload + + +def test_token_cookie_tamper_returns_none(app, build_token_payload): + settings = app.extensions["bff_settings"] + response = app.response_class() + token_payload = build_token_payload() + + set_token_cookies(response, token_payload, settings) + cookie_map = _extract_cookie_map(response) + + names = token_cookie_names(settings) + tampered = dict(cookie_map) + original = tampered[names["access_token"]] + tampered[names["access_token"]] = original[:-1] + ( + "A" if original[-1] != "A" else "B" + ) + + assert load_token_from_cookies(tampered, settings) is None + + +def test_token_cookie_missing_component_returns_none(app, build_token_payload): + settings = app.extensions["bff_settings"] + response = app.response_class() + token_payload = build_token_payload() + + set_token_cookies(response, token_payload, settings) + cookie_map = _extract_cookie_map(response) + + names = token_cookie_names(settings) + missing_id = dict(cookie_map) + missing_id.pop(names["id_token"]) + + assert load_token_from_cookies(missing_id, settings) is None + + +def test_token_cookie_oversize_raises_error(app, build_token_payload): + settings = app.extensions["bff_settings"] + response = app.response_class() + token_payload = build_token_payload(access_token="a" * 7000) + + with pytest.raises(TokenCookieTooLargeError): + set_token_cookies(response, token_payload, settings) + + +def test_token_cookie_values_do_not_expose_plaintext(app, build_token_payload): + settings = app.extensions["bff_settings"] + response = app.response_class() + token_payload = build_token_payload( + access_token="access-token-plaintext", + refresh_token="refresh-token-plaintext", + id_token="id-token-plaintext", + ) + + set_token_cookies(response, token_payload, settings) + cookie_map = _extract_cookie_map(response) + + assert all("access-token-plaintext" not in value for value in cookie_map.values()) + assert all("refresh-token-plaintext" not in value for value in cookie_map.values()) + assert all("id-token-plaintext" not in value for value in cookie_map.values()) diff --git a/bff/uv.lock b/bff/uv.lock index 89a37156..6c7c9b6e 100644 --- a/bff/uv.lock +++ b/bff/uv.lock @@ -37,6 +37,7 @@ version = "0.3.0" source = { virtual = "." } dependencies = [ { name = "authlib" }, + { name = "cryptography" }, { name = "flask" }, { name = "flask-cors" }, { name = "flask-session" }, @@ -54,6 +55,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "authlib" }, + { name = "cryptography" }, { name = "flask", specifier = "~=3.1.2" }, { name = "flask-cors" }, { name = "flask-session" }, From dc9228da8fac1776e94dd1695321d2737d6fd9e3 Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 11 Mar 2026 15:38:20 +0100 Subject: [PATCH 22/24] Update release workflow to use GHCR and improve BFF Docker image publishing - Adjusted `release.yml` to push BFF container images to GitHub Container Registry (GHCR). - Updated authentication to leverage `GITHUB_TOKEN` and repository-specific image paths. - Documented new image publishing process in README, including tag conventions. --- .github/workflows/release.yml | 11 +++++++---- README.md | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be8e9308..1a5d6049 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,9 @@ jobs: if: ${{ !github.event.release.draft }} runs-on: ubuntu-latest needs: validate-version + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v6 @@ -37,15 +40,15 @@ jobs: - name: Login to registry uses: docker/login-action@v3 with: - registry: ${{ secrets.OCI_REGISTRY_HOST }} - username: ${{ secrets.OCI_REGISTRY_USERNAME }} - password: ${{ secrets.OCI_REGISTRY_PASSWORD }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Compute image metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ secrets.OCI_REGISTRY_HOST }}/${{ secrets.OCI_BFF_IMAGE_REPOSITORY }} + images: ghcr.io/${{ github.repository }}/bff tags: | type=raw,value=${{ github.event.release.tag_name }} type=raw,value=latest,enable=${{ !github.event.release.prerelease }} diff --git a/README.md b/README.md index b22e1983..ac924a5b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,13 @@ python3 scripts/wefa_version.py set 1.0.0-rc.1 Use `--dry-run` to preview changes and `--allow-dirty-version-files` only when you intentionally need to override preflight checks. +### BFF Container Image + +When a GitHub release is published, CI builds and pushes the BFF Docker image to GitHub Container Registry (GHCR): + +- `ghcr.io/n-side-dev/wefa/bff:` +- `ghcr.io/n-side-dev/wefa/bff:latest` for non-prerelease tags only + ## Contributing Contributions are welcome! Start with open issues or propose new ideas through GitHub discussions. Please read [Django CONTRIBUTE](django/CONTRIBUTE.md) and/or [Vue CONTRIBUTE](vue/CONTRIBUTE.md) for the current contribution workflow. From b5a6d0f442b40ad782b6e2fb295afce3a164272a Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 11 Mar 2026 15:59:02 +0100 Subject: [PATCH 23/24] Add SemVer and PEP 440 compatibility for version management - Introduce functions to convert and validate SemVer and PEP 440 formats. - Implement transactional updates with rollback in case of failure. - Update version handling logic to support format-specific validation. - Add comprehensive test coverage for versioning scenarios. - Document versioning rules and conversions in README and contributor guides. - Configure CI workflow for scripts and unit test execution. --- .github/workflows/scripts.yml | 29 ++++ README.md | 4 + scripts/test_wefa_version.py | 99 +++++++++++++ scripts/wefa_version.py | 271 +++++++++++++++++++++++++++------- vue/CONTRIBUTE.md | 3 + vue/README.md | 6 +- 6 files changed, 357 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/scripts.yml create mode 100644 scripts/test_wefa_version.py diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 00000000..bcffd54c --- /dev/null +++ b/.github/workflows/scripts.yml @@ -0,0 +1,29 @@ +name: Scripts CI + +on: + pull_request: + paths: + - 'scripts/**' + - '.github/workflows/scripts.yml' + push: + branches: [ main, develop ] + paths: + - 'scripts/**' + - '.github/workflows/scripts.yml' + workflow_dispatch: + +jobs: + tests: + name: Scripts Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run scripts unit tests + run: python3 -m unittest discover -s scripts -p 'test_*.py' diff --git a/README.md b/README.md index ac924a5b..66826ef8 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ python3 scripts/wefa_version.py set 1.0.0-rc.1 Use `--dry-run` to preview changes and `--allow-dirty-version-files` only when you intentionally need to override preflight checks. +Use SemVer for CLI inputs and release tags (for example `1.2.3-rc.1`). For prereleases, only +`alpha.`, `beta.`, and `rc.` are supported. Python project files are written in PEP 440 +equivalent form (`1.2.3a1`, `1.2.3b1`, `1.2.3rc1`) by the orchestrator. + ### BFF Container Image When a GitHub release is published, CI builds and pushes the BFF Docker image to GitHub Container Registry (GHCR): diff --git a/scripts/test_wefa_version.py b/scripts/test_wefa_version.py new file mode 100644 index 00000000..ad7fb2dd --- /dev/null +++ b/scripts/test_wefa_version.py @@ -0,0 +1,99 @@ +import argparse +import importlib.util +import sys +import tempfile +from pathlib import Path +from unittest import TestCase, mock + + +MODULE_PATH = Path(__file__).with_name("wefa_version.py") +SPEC = importlib.util.spec_from_file_location("wefa_version_module", MODULE_PATH) +assert SPEC and SPEC.loader +wefa_version = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = wefa_version +SPEC.loader.exec_module(wefa_version) + + +MIXED_RC_VERSIONS = { + "vue/package.json": "1.0.0-rc.1", + "vue/package-lock.json": "1.0.0-rc.1", + "django/pyproject.toml": "1.0.0rc1", + "django/uv.lock": "1.0.0rc1", + "django/nside_wefa/__init__.py": "1.0.0rc1", + "bff/pyproject.toml": "1.0.0rc1", + "bff/uv.lock": "1.0.0rc1", +} + + +class WefaVersionTests(TestCase): + def test_semver_to_pep440_conversion(self) -> None: + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-rc.4", flag_name="version"), + "1.2.3rc4", + ) + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-alpha.2", flag_name="version"), + "1.2.3a2", + ) + self.assertEqual( + wefa_version.semver_to_python_version("1.2.3-beta.7", flag_name="version"), + "1.2.3b7", + ) + + def test_pep440_to_semver_conversion(self) -> None: + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3rc4", flag_name="version"), + "1.2.3-rc.4", + ) + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3a2", flag_name="version"), + "1.2.3-alpha.2", + ) + self.assertEqual( + wefa_version.pep440_to_semver("1.2.3b7", flag_name="version"), + "1.2.3-beta.7", + ) + + def test_rejects_unsupported_semver_prerelease_label(self) -> None: + with self.assertRaisesRegex(ValueError, "prerelease label must be one of"): + wefa_version.build_version_targets("1.2.3-preview.1", flag_name="version") + + def test_unified_version_accepts_mixed_semver_and_pep440(self) -> None: + self.assertEqual(wefa_version.unified_version(MIXED_RC_VERSIONS), "1.0.0-rc.1") + + def test_check_expect_semver_matches_pep440_python_sources(self) -> None: + args = argparse.Namespace(expect="1.0.0-rc.1") + with mock.patch.object(wefa_version, "collect_versions", return_value=MIXED_RC_VERSIONS): + self.assertEqual(wefa_version.cmd_check(args), 0) + + def test_post_update_assertions_require_expected_serialization(self) -> None: + targets = wefa_version.build_version_targets("1.0.0-rc.1", flag_name="version") + + with mock.patch.object(wefa_version, "collect_versions", return_value=MIXED_RC_VERSIONS): + wefa_version.post_update_assertions(targets) + + wrong_versions = dict(MIXED_RC_VERSIONS) + wrong_versions["django/pyproject.toml"] = "1.0.0-rc.1" + with mock.patch.object(wefa_version, "collect_versions", return_value=wrong_versions): + with self.assertRaisesRegex(RuntimeError, "expected 1.0.0rc1"): + wefa_version.post_update_assertions(targets) + + def test_transactional_update_restores_files_on_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "version.txt" + path.write_text("before\n", encoding="utf-8") + source = wefa_version.VersionSource( + path=path, + reader=lambda p: p.read_text(encoding="utf-8").strip(), + version_format=wefa_version.VERSION_FORMAT_SEMVER, + ) + + def mutate() -> None: + path.write_text("after\n", encoding="utf-8") + raise RuntimeError("boom") + + with mock.patch.object(wefa_version, "VERSION_SOURCES", (source,)): + with self.assertRaisesRegex(RuntimeError, "restored tracked version files"): + wefa_version.run_transactional_version_update(mutate) + + self.assertEqual(path.read_text(encoding="utf-8"), "before\n") diff --git a/scripts/wefa_version.py b/scripts/wefa_version.py index f150c6c1..9ab4f631 100644 --- a/scripts/wefa_version.py +++ b/scripts/wefa_version.py @@ -26,14 +26,41 @@ r"))?$" ) +PEP440_RE = re.compile( + r"^(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)\." + r"(?P0|[1-9]\d*)" + r"(?:(?P