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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-06 14:56:07 +00:00
parent a3a38e85d2
commit af2a020696
14 changed files with 272 additions and 5 deletions

View file

@ -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=<generate: python -c "import secrets; print(secrets.token_hex(32))">

11
Dockerfile Normal file
View file

@ -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"]

0
app/auth/__init__.py Normal file
View file

5
app/auth/dependencies.py Normal file
View file

@ -0,0 +1,5 @@
from fastapi import Request
def get_current_user(request: Request) -> dict | None:
return request.session.get("user")

28
app/auth/middleware.py Normal file
View file

@ -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)

37
app/auth/msal_client.py Normal file
View file

@ -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

55
app/auth/routes.py Normal file
View file

@ -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)

View file

@ -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", "")

View file

@ -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:

View file

@ -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;

View file

@ -12,8 +12,18 @@
<body>
<div class="container">
<header>
<h1>PIMCO Chart Generator</h1>
<p class="subtitle">Upload data and describe your chart to generate publication-quality SVGs</p>
<div class="header-top">
<div>
<h1>PIMCO Chart Generator</h1>
<p class="subtitle">Upload data and describe your chart to generate publication-quality SVGs</p>
</div>
{% if user %}
<nav class="user-nav">
<span class="user-name">{{ user.name }}</span>
<a href="/auth/logout" class="sign-out-link">Sign Out</a>
</nav>
{% endif %}
</div>
</header>
{% block content %}{% endblock %}
</div>

45
deploy.sh Executable file
View file

@ -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 " <Directory $WEB_STATIC/>"
echo " Require all granted"
echo " </Directory>"

20
docker-compose.yml Normal file
View file

@ -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

View file

@ -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