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:
parent
a3a38e85d2
commit
af2a020696
14 changed files with 272 additions and 5 deletions
|
|
@ -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
11
Dockerfile
Normal 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
0
app/auth/__init__.py
Normal file
5
app/auth/dependencies.py
Normal file
5
app/auth/dependencies.py
Normal 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
28
app/auth/middleware.py
Normal 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
37
app/auth/msal_client.py
Normal 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
55
app/auth/routes.py
Normal 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)
|
||||
|
|
@ -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", "")
|
||||
|
|
|
|||
19
app/main.py
19
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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
45
deploy.sh
Executable 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
20
docker-compose.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue