From af2a02069665c3a2722671c74b0b5e9c5913811c Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 6 Mar 2026 14:56:07 +0000 Subject: [PATCH] Add Azure AD SSO (MSAL/PKCE) and Docker deployment - Azure AD authentication via MSAL PublicClientApplication with PKCE flow - Session middleware, auth middleware, login/callback/logout routes - Dockerfile, docker-compose.yml for port 8569, output volume - Idempotent deploy.sh with static file copy and health wait - User nav bar in base template with Sign Out link Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 +++ Dockerfile | 11 ++++++++ app/auth/__init__.py | 0 app/auth/dependencies.py | 5 ++++ app/auth/middleware.py | 28 ++++++++++++++++++++ app/auth/msal_client.py | 37 +++++++++++++++++++++++++++ app/auth/routes.py | 55 ++++++++++++++++++++++++++++++++++++++++ app/config.py | 5 ++++ app/main.py | 19 +++++++++++--- app/static/style.css | 32 +++++++++++++++++++++++ app/templates/base.html | 14 ++++++++-- deploy.sh | 45 ++++++++++++++++++++++++++++++++ docker-compose.yml | 20 +++++++++++++++ requirements.txt | 2 ++ 14 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 Dockerfile create mode 100644 app/auth/__init__.py create mode 100644 app/auth/dependencies.py create mode 100644 app/auth/middleware.py create mode 100644 app/auth/msal_client.py create mode 100644 app/auth/routes.py create mode 100755 deploy.sh create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example index ff23402..0971b0e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx +AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385 +AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef +AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/Pimco-charts/auth/callback +SESSION_SECRET_KEY= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9e802e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcairo2 libpango-1.0-0 libpangocairo-1.0-0 curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /app/output +EXPOSE 8569 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8569", "--root-path", "/Pimco-charts"] diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py new file mode 100644 index 0000000..9425bff --- /dev/null +++ b/app/auth/dependencies.py @@ -0,0 +1,5 @@ +from fastapi import Request + + +def get_current_user(request: Request) -> dict | None: + return request.session.get("user") diff --git a/app/auth/middleware.py b/app/auth/middleware.py new file mode 100644 index 0000000..eedc0eb --- /dev/null +++ b/app/auth/middleware.py @@ -0,0 +1,28 @@ +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +EXEMPT_PATHS = {"/auth/login", "/auth/callback", "/auth/logout"} + + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Strip root_path prefix for matching + root_path = request.scope.get("root_path", "") + if root_path and path.startswith(root_path): + path = path[len(root_path):] + + if path in EXEMPT_PATHS: + return await call_next(request) + + if not request.session.get("user"): + if request.headers.get("HX-Request"): + return Response( + status_code=401, + headers={"HX-Redirect": "/auth/login"}, + ) + return RedirectResponse(url="/auth/login") + + return await call_next(request) diff --git a/app/auth/msal_client.py b/app/auth/msal_client.py new file mode 100644 index 0000000..9bf3ea2 --- /dev/null +++ b/app/auth/msal_client.py @@ -0,0 +1,37 @@ +import msal +import secrets +import hashlib +import base64 + +from app.config import AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_REDIRECT_URI + +AUTHORITY = f"https://login.microsoftonline.com/{AZURE_TENANT_ID}" +SCOPES = ["openid", "profile", "email"] + + +def generate_pkce_pair() -> tuple[str, str]: + verifier = secrets.token_urlsafe(48) + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b"=").decode() + return verifier, challenge + + +def get_msal_app(): + return msal.PublicClientApplication(AZURE_CLIENT_ID, authority=AUTHORITY) + + +def build_auth_url(state: str, challenge: str) -> str: + return get_msal_app().get_authorization_request_url( + SCOPES, state=state, redirect_uri=AZURE_REDIRECT_URI, + code_challenge=challenge, code_challenge_method="S256" + ) + + +def exchange_code(code: str, verifier: str) -> dict: + result = get_msal_app().acquire_token_by_authorization_code( + code, SCOPES, redirect_uri=AZURE_REDIRECT_URI, code_verifier=verifier + ) + if "error" in result: + raise ValueError(result.get("error_description", "Auth failed")) + return result diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..5402566 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,55 @@ +import secrets + +from fastapi import APIRouter, Request +from fastapi.responses import RedirectResponse + +from app.auth.msal_client import build_auth_url, exchange_code, generate_pkce_pair +from app.config import AZURE_TENANT_ID, AZURE_REDIRECT_URI + +router = APIRouter(prefix="/auth") + +LOGOUT_URL = ( + f"https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/v2.0/logout" + f"?post_logout_redirect_uri=https://ai-sandbox.oliver.solutions/Pimco-charts" +) + + +@router.get("/login") +async def login(request: Request): + state = secrets.token_urlsafe(16) + verifier, challenge = generate_pkce_pair() + request.session["oauth_state"] = state + request.session["pkce_verifier"] = verifier + auth_url = build_auth_url(state=state, challenge=challenge) + return RedirectResponse(url=auth_url) + + +@router.get("/callback") +async def callback(request: Request, code: str = "", state: str = "", error: str = ""): + if error: + return RedirectResponse(url="/auth/login") + + stored_state = request.session.pop("oauth_state", None) + verifier = request.session.pop("pkce_verifier", None) + + if not stored_state or state != stored_state or not verifier: + return RedirectResponse(url="/auth/login") + + try: + result = exchange_code(code=code, verifier=verifier) + except ValueError: + return RedirectResponse(url="/auth/login") + + claims = result.get("id_token_claims", {}) + request.session["user"] = { + "name": claims.get("name", claims.get("preferred_username", "User")), + "email": claims.get("email", claims.get("preferred_username", "")), + "oid": claims.get("oid", ""), + } + return RedirectResponse(url="/") + + +@router.get("/logout") +async def logout(request: Request): + request.session.clear() + return RedirectResponse(url=LOGOUT_URL) diff --git a/app/config.py b/app/config.py index bc85ea9..f44521e 100644 --- a/app/config.py +++ b/app/config.py @@ -15,3 +15,8 @@ OUTPUT_DIR = BASE_DIR / "output" ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") CLAUDE_MODEL = "claude-opus-4-6" + +AZURE_TENANT_ID = os.getenv("AZURE_TENANT_ID", "") +AZURE_CLIENT_ID = os.getenv("AZURE_CLIENT_ID", "") +AZURE_REDIRECT_URI = os.getenv("AZURE_REDIRECT_URI", "") +SESSION_SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "") diff --git a/app/main.py b/app/main.py index ab8bb8e..3103783 100644 --- a/app/main.py +++ b/app/main.py @@ -10,8 +10,12 @@ from fastapi import FastAPI, UploadFile, File, Form, Request from fastapi.responses import HTMLResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware +from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from app.config import OUTPUT_DIR, STATIC_DIR, TEMPLATES_DIR +from app.config import OUTPUT_DIR, STATIC_DIR, TEMPLATES_DIR, SESSION_SECRET_KEY +from app.auth.middleware import AuthMiddleware +from app.auth.routes import router as auth_router from app.data.loader import load_file from app.data.analyzer import summarize_data from app.data.transformer import prepare_dataframe @@ -21,7 +25,11 @@ from app.models.chart_spec import ChartSpec from app.models.style import LAYOUT from app.renderer.engine import render_chart -app = FastAPI(title="PIMCO Chart Generator") +app = FastAPI(title="PIMCO Chart Generator", root_path="/Pimco-charts") +app.add_middleware(AuthMiddleware) +app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET_KEY, https_only=True, same_site="lax") +app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="127.0.0.1") +app.include_router(auth_router) app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -33,7 +41,10 @@ _sessions: dict[str, dict] = {} @app.get("/", response_class=HTMLResponse) async def index(request: Request): - return templates.TemplateResponse("upload.html", {"request": request}) + return templates.TemplateResponse("upload.html", { + "request": request, + "user": request.session.get("user"), + }) @app.post("/generate", response_class=HTMLResponse) @@ -92,6 +103,7 @@ async def generate( "spec_json": spec_json, "session_id": session_id, "history": _sessions[session_id]["history"], + "user": request.session.get("user"), }) except Exception as e: @@ -144,6 +156,7 @@ async def refine( "spec_json": spec_json, "session_id": session_id, "history": history, + "user": request.session.get("user"), }) except Exception as e: diff --git a/app/static/style.css b/app/static/style.css index cadfdc5..3295f1b 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -21,6 +21,38 @@ header { margin-bottom: 40px; } +.header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.user-nav { + display: flex; + align-items: center; + gap: 12px; + padding-top: 4px; + font-size: 0.9rem; +} + +.user-name { + color: #555; +} + +.sign-out-link { + color: #003D5C; + text-decoration: none; + border: 1px solid #003D5C; + padding: 3px 10px; + border-radius: 3px; + font-size: 0.85rem; +} + +.sign-out-link:hover { + background: #003D5C; + color: #fff; +} + header h1 { font-family: 'Roboto Condensed', sans-serif; font-size: 2rem; diff --git a/app/templates/base.html b/app/templates/base.html index d6ea21a..33bac48 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -12,8 +12,18 @@
-

PIMCO Chart Generator

-

Upload data and describe your chart to generate publication-quality SVGs

+
+
+

PIMCO Chart Generator

+

Upload data and describe your chart to generate publication-quality SVGs

+
+ {% if user %} + + {% endif %} +
{% block content %}{% endblock %}
diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..2a2a4cb --- /dev/null +++ b/deploy.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +WEB_STATIC="/var/www/html/Pimco-charts/static" + +cd "$REPO_DIR" + +# 1. Pull latest code +git pull origin main + +# 2. Build Docker image (uses cache) +docker compose build + +# 3. Start/restart container +docker compose up -d --remove-orphans + +# 4. Prune dangling images +docker image prune -f + +# 5. Copy static files to web directory +mkdir -p "$WEB_STATIC" +rm -rf "$WEB_STATIC"/* +cp -r "$REPO_DIR/app/static/." "$WEB_STATIC/" + +# 6. Wait for health +echo "Waiting for container to become healthy..." +for i in $(seq 1 12); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' pimco-charts 2>/dev/null || echo "starting") + [ "$STATUS" = "healthy" ] && echo "Healthy." && break + echo " [$i/12] status=$STATUS, waiting 5s..." + sleep 5 +done + +echo "=== Deploy complete. App running on 127.0.0.1:8569 ===" +echo "Configure Apache to proxy /Pimco-charts/ -> http://127.0.0.1:8569/" +echo "Static files served from: $WEB_STATIC" +echo "" +echo "Apache config snippet:" +echo " ProxyPass /Pimco-charts/static/ !" +echo " ProxyPass /Pimco-charts/ http://127.0.0.1:8569/" +echo " ProxyPassReverse /Pimco-charts/ http://127.0.0.1:8569/" +echo " Alias /Pimco-charts/static/ $WEB_STATIC/" +echo " " +echo " Require all granted" +echo " " diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..51ec089 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + image: pimco-charts:latest + container_name: pimco-charts + restart: unless-stopped + ports: + - "127.0.0.1:8569:8569" + env_file: + - .env + volumes: + - ./output:/app/output + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8569/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s diff --git a/requirements.txt b/requirements.txt index b9ddce6..4b09e96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ annotated-doc==0.0.4 +msal==1.31.0 +itsdangerous==2.2.0 annotated-types==0.7.0 anthropic==0.84.0 anyio==4.12.1