From 80b086f8a365d53868cb35600badea87fbf73f1c Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sun, 14 Jun 2026 17:55:47 +0200 Subject: [PATCH] add authentication layer to python service --- infra/docker-compose.override.yml | 3 ++ infra/docker-compose.yml | 3 ++ infra/helm/team-devoops/values.yaml | 3 ++ services/py-genai-helper/app.py | 3 ++ services/py-genai-helper/auth.py | 64 +++++++++++++++++++++++ services/py-genai-helper/requirements.txt | 1 + 6 files changed, 77 insertions(+) create mode 100644 services/py-genai-helper/auth.py diff --git a/infra/docker-compose.override.yml b/infra/docker-compose.override.yml index 79bf897..d79336e 100644 --- a/infra/docker-compose.override.yml +++ b/infra/docker-compose.override.yml @@ -35,6 +35,9 @@ services: - "8080:8080" py-genai-helper: + environment: + - KEYCLOAK_ISSUER_URL=http://localhost:8081/auth/realms/devops + - KEYCLOAK_JWKS_URL=http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs labels: !override - "traefik.enable=true" - "traefik.http.routers.py-genai-helper.entrypoints=web" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 4bd96d2..889e33a 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -6,6 +6,9 @@ services: container_name: py-genai-helper env_file: - ../services/py-genai-helper/.env + environment: + - KEYCLOAK_ISSUER_URL=https://team-devoops.uaenorth.cloudapp.azure.com/auth/realms/devops + - KEYCLOAK_JWKS_URL=http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs expose: - 5000 labels: diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index dc771c3..d576cdb 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -232,6 +232,9 @@ services: health: /health stripPrefix: true envFromSecret: genai-env + env: + KEYCLOAK_ISSUER_URL: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" + KEYCLOAK_JWKS_URL: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" resources: requests: cpu: 100m diff --git a/services/py-genai-helper/app.py b/services/py-genai-helper/app.py index f8ccd5a..c6da44d 100644 --- a/services/py-genai-helper/app.py +++ b/services/py-genai-helper/app.py @@ -1,11 +1,13 @@ from flask import Flask, request +from auth import require_auth from service import generate_rag_response, hello app = Flask("genai-service") @app.route("/hello") +@require_auth def hello_world(): hello_message = hello() return f"

{hello_message}

" @@ -17,6 +19,7 @@ def health(): @app.route("/rag-response", methods=["POST"]) +@require_auth def rag_response(): # Get the json of the object. force=True ignores the stated MimeType data = request.get_json(force=True) or {} diff --git a/services/py-genai-helper/auth.py b/services/py-genai-helper/auth.py new file mode 100644 index 0000000..5de884b --- /dev/null +++ b/services/py-genai-helper/auth.py @@ -0,0 +1,64 @@ +import os +from functools import wraps + +import jwt +import requests +from flask import request + +_jwks_cache: dict | None = None + +KEYCLOAK_ISSUER_URL = os.environ.get( + "KEYCLOAK_ISSUER_URL", + "http://keycloak:8080/auth/realms/devops", +) +# Separate JWKS URL allows using the internal service name for fetching +# while the issuer URL matches the public hostname in the token's `iss` claim. +_JWKS_URL = os.environ.get( + "KEYCLOAK_JWKS_URL", + f"{KEYCLOAK_ISSUER_URL}/protocol/openid-connect/certs", +) + + +def _fetch_jwks() -> dict: + response = requests.get(_JWKS_URL, timeout=5) + response.raise_for_status() + return response.json() + + +def _get_signing_key(token: str) -> jwt.PyJWK: + global _jwks_cache + if _jwks_cache is None: + _jwks_cache = _fetch_jwks() + try: + return jwt.PyJWKClient(_JWKS_URL, jwks_data=_jwks_cache).get_signing_key_from_jwt(token) + except jwt.exceptions.PyJWKClientError: + # Key not found in cache — Keycloak may have rotated keys; refresh once. + _jwks_cache = _fetch_jwks() + return jwt.PyJWKClient(_JWKS_URL, jwks_data=_jwks_cache).get_signing_key_from_jwt(token) + + +def require_auth(f): + @wraps(f) + def decorated(*args, **kwargs): + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return {"error": "Missing or invalid Authorization header"}, 401 + + token = auth_header[len("Bearer ") :] + try: + signing_key = _get_signing_key(token) + jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + options={"verify_aud": False}, + issuer=KEYCLOAK_ISSUER_URL, + ) + except jwt.ExpiredSignatureError: + return {"error": "Token has expired"}, 401 + except Exception: + return {"error": "Invalid token"}, 401 + + return f(*args, **kwargs) + + return decorated diff --git a/services/py-genai-helper/requirements.txt b/services/py-genai-helper/requirements.txt index 874ce76..6eac4d9 100644 --- a/services/py-genai-helper/requirements.txt +++ b/services/py-genai-helper/requirements.txt @@ -54,6 +54,7 @@ pydantic-settings==2.14.1 pydantic_core==2.46.4 pypdf==5.6.0 python-dotenv==1.2.2 +PyJWT[crypto]==2.10.1 PyYAML==6.0.3 regex==2026.5.9 requests==2.34.0