setup workos
This commit was merged in pull request #10.
This commit is contained in:
+122
-102
@@ -4,20 +4,17 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from os import getenv
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from pipelines.web import repository
|
||||
from pipelines.web import auth, repository
|
||||
from pipelines.web.db import session_scope, validate_database_connection
|
||||
from pipelines.web.repository import Chamber, RankingResult
|
||||
from pipelines.web.scoring import normalize_issues
|
||||
@@ -28,10 +25,7 @@ TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||
ADMIN_USERNAME = "admin"
|
||||
ADMIN_PASSWORD = "admin"
|
||||
SESSION_COOKIE = "nornsight_admin"
|
||||
SESSION_SECRET = "nornsight-local-dev-session-secret"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -62,72 +56,69 @@ def healthz() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
def login(request: Request) -> Response:
|
||||
"""Render the integrated login page."""
|
||||
next_path = _safe_next_path(request.query_params.get("next"))
|
||||
if _authenticated_user(request) is not None:
|
||||
return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request) -> Response:
|
||||
"""Render the public home page."""
|
||||
current_user = auth.get_current_session(request)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
"home.html",
|
||||
{
|
||||
"error": "",
|
||||
"is_authenticated": False,
|
||||
"show_primary_nav": False,
|
||||
"next_path": next_path,
|
||||
"username": "",
|
||||
**_auth_context(current_user),
|
||||
"auth_error": request.query_params.get("auth_error") == "1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/login", response_class=HTMLResponse)
|
||||
async def login_submit(request: Request) -> Response:
|
||||
"""Authenticate the hard-coded admin user and set a session cookie."""
|
||||
form = parse_qs((await request.body()).decode())
|
||||
username = form.get("username", [""])[0]
|
||||
password = form.get("password", [""])[0]
|
||||
next_path = _safe_next_path(form.get("next", [request.query_params.get("next")])[0])
|
||||
@app.get("/login")
|
||||
def login(request: Request) -> Response:
|
||||
"""Start the WorkOS hosted login flow."""
|
||||
next_path = auth.safe_next_path(request.query_params.get("next"))
|
||||
current_user = auth.get_current_session(request)
|
||||
if current_user is not None:
|
||||
return RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||
return RedirectResponse(
|
||||
auth.build_authorization_url(next_path),
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
username_ok = secrets.compare_digest(username, ADMIN_USERNAME)
|
||||
password_ok = secrets.compare_digest(password, ADMIN_PASSWORD)
|
||||
if not (username_ok and password_ok):
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"login.html",
|
||||
{
|
||||
"error": "Invalid username or password.",
|
||||
"is_authenticated": False,
|
||||
"show_primary_nav": False,
|
||||
"next_path": next_path,
|
||||
"username": username,
|
||||
},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
response = RedirectResponse(next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||
@app.get("/callback")
|
||||
def callback(request: Request) -> Response:
|
||||
"""Exchange the WorkOS code for a sealed session cookie."""
|
||||
try:
|
||||
result = auth.exchange_code(request)
|
||||
except Exception:
|
||||
logger.exception("WorkOS callback exchange failed.")
|
||||
response = RedirectResponse("/?auth_error=1", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_delete_auth_cookie(response)
|
||||
return response
|
||||
|
||||
config = auth.get_auth_config()
|
||||
response = RedirectResponse(result.next_path, status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
_sign_session(username),
|
||||
config.session_cookie_name,
|
||||
result.sealed_session,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.secure_cookies,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
def logout() -> Response:
|
||||
"""Clear the local admin session."""
|
||||
response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
@app.post("/logout")
|
||||
def logout(request: Request) -> Response:
|
||||
"""End the WorkOS session and clear the local sealed session cookie."""
|
||||
response = RedirectResponse(auth.get_logout_url(request), status_code=status.HTTP_303_SEE_OTHER)
|
||||
_delete_auth_cookie(response)
|
||||
return response
|
||||
|
||||
|
||||
def require_admin(request: Request) -> str:
|
||||
"""Redirect unauthenticated users to the in-site login page."""
|
||||
username = _authenticated_user(request)
|
||||
if username is not None:
|
||||
return username
|
||||
def require_user(request: Request) -> auth.AuthSession:
|
||||
"""Redirect unauthenticated users to the WorkOS sign-in flow."""
|
||||
current_user = auth.get_current_session(request)
|
||||
if current_user is not None:
|
||||
return current_user
|
||||
next_path = request.url.path
|
||||
if request.url.query:
|
||||
next_path = f"{next_path}?{request.url.query}"
|
||||
@@ -138,87 +129,64 @@ def require_admin(request: Request) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _authenticated_user(request: Request) -> str | None:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
username, signature = token.split(":", 1)
|
||||
except ValueError:
|
||||
return None
|
||||
if username != ADMIN_USERNAME:
|
||||
return None
|
||||
expected = _session_signature(username)
|
||||
if secrets.compare_digest(signature, expected):
|
||||
return username
|
||||
return None
|
||||
def require_admin(current_user: auth.AuthSession = Depends(require_user)) -> auth.AuthSession:
|
||||
"""Restrict a route to WorkOS users with the admin role."""
|
||||
if current_user.is_admin:
|
||||
return current_user
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
|
||||
|
||||
|
||||
def _sign_session(username: str) -> str:
|
||||
return f"{username}:{_session_signature(username)}"
|
||||
|
||||
|
||||
def _session_signature(username: str) -> str:
|
||||
return hmac.new(
|
||||
SESSION_SECRET.encode(),
|
||||
username.encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _safe_next_path(value: str | None) -> str:
|
||||
if value and value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
return "/"
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
def dashboard(
|
||||
request: Request, current_user: auth.AuthSession = Depends(require_user)
|
||||
) -> Response:
|
||||
"""Render the full dashboard page."""
|
||||
context = _dashboard_context(request)
|
||||
context = {**_auth_context(current_user), **_dashboard_context(request)}
|
||||
if request.headers.get("hx-request") == "true":
|
||||
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
||||
return templates.TemplateResponse(request, "dashboard.html", context)
|
||||
|
||||
|
||||
@app.get("/partials/dashboard", response_class=HTMLResponse)
|
||||
def dashboard_partial(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def dashboard_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
|
||||
"""Render the filter-dependent dashboard body."""
|
||||
context = _dashboard_context(request)
|
||||
return templates.TemplateResponse(request, "partials/_dashboard.html", context)
|
||||
|
||||
|
||||
@app.get("/partials/issues", response_class=HTMLResponse)
|
||||
def issues_partial(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def issues_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
|
||||
"""Render only issue filters."""
|
||||
context = _dashboard_context(request)
|
||||
return templates.TemplateResponse(request, "partials/_issue_filters.html", context)
|
||||
|
||||
|
||||
@app.get("/partials/rankings", response_class=HTMLResponse)
|
||||
def rankings_partial(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def rankings_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
|
||||
"""Render only ranking panels."""
|
||||
context = _dashboard_context(request)
|
||||
return templates.TemplateResponse(request, "partials/_rankings.html", context)
|
||||
|
||||
|
||||
@app.get("/partials/chart", response_class=HTMLResponse)
|
||||
def chart_partial(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def chart_partial(request: Request, _: auth.AuthSession = Depends(require_user)) -> Response:
|
||||
"""Render only the SVG chart panel."""
|
||||
context = _dashboard_context(request)
|
||||
return templates.TemplateResponse(request, "partials/_chart.html", context)
|
||||
|
||||
|
||||
@app.get("/legislators", response_class=HTMLResponse)
|
||||
def legislators(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def legislators(
|
||||
request: Request, current_user: auth.AuthSession = Depends(require_user)
|
||||
) -> Response:
|
||||
"""Render the legislator profile/search page."""
|
||||
context = _legislators_context(request)
|
||||
context = {**_auth_context(current_user), **_legislators_context(request)}
|
||||
return templates.TemplateResponse(request, "legislators.html", context)
|
||||
|
||||
|
||||
@app.get("/partials/legislator-suggestions", response_class=HTMLResponse)
|
||||
def legislator_suggestions_partial(
|
||||
request: Request, _: str = Depends(require_admin)
|
||||
request: Request, _: auth.AuthSession = Depends(require_user)
|
||||
) -> Response:
|
||||
"""Render legislator search suggestions for the HTMX typeahead."""
|
||||
query = request.query_params.get("q", "").strip()
|
||||
@@ -238,12 +206,29 @@ def legislator_suggestions_partial(
|
||||
|
||||
|
||||
@app.get("/compare", response_class=HTMLResponse)
|
||||
def compare(request: Request, _: str = Depends(require_admin)) -> Response:
|
||||
def compare(
|
||||
request: Request, current_user: auth.AuthSession = Depends(require_user)
|
||||
) -> Response:
|
||||
"""Render the legislator radar comparison page."""
|
||||
context = _compare_context(request)
|
||||
context = {**_auth_context(current_user), **_compare_context(request)}
|
||||
return templates.TemplateResponse(request, "compare.html", context)
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
def admin_page(
|
||||
request: Request, current_user: auth.AuthSession = Depends(require_admin)
|
||||
) -> Response:
|
||||
"""Render the admin-only placeholder page."""
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin.html",
|
||||
{
|
||||
**_auth_context(current_user),
|
||||
"organization_id": auth.get_auth_config().organization_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_context(request: Request) -> dict[str, Any]:
|
||||
state = _parse_state(request)
|
||||
base_context: dict[str, Any] = {
|
||||
@@ -263,6 +248,7 @@ def _dashboard_context(request: Request) -> dict[str, Any]:
|
||||
"has_scores": False,
|
||||
"empty_message": "",
|
||||
"build_url": _build_url,
|
||||
"build_dashboard_partial_url": _build_dashboard_partial_url,
|
||||
"toggle_compare": _toggle_compare,
|
||||
}
|
||||
with session_scope() as session:
|
||||
@@ -520,10 +506,29 @@ def _build_url(
|
||||
for legislator_id in chosen_compare:
|
||||
params.append(("compare", str(legislator_id)))
|
||||
if not params:
|
||||
return "/"
|
||||
return "/dashboard"
|
||||
from urllib.parse import urlencode
|
||||
|
||||
return f"/?{urlencode(params, doseq=True)}"
|
||||
return f"/dashboard?{urlencode(params, doseq=True)}"
|
||||
|
||||
|
||||
def _build_dashboard_partial_url(
|
||||
request: Request,
|
||||
*,
|
||||
issues: list[str] | None = None,
|
||||
chamber: str | None = None,
|
||||
congress: int | None = None,
|
||||
compare: list[int] | None = None,
|
||||
) -> str:
|
||||
"""Return the HTMX endpoint matching the current dashboard query state."""
|
||||
dashboard_url = _build_url(
|
||||
request,
|
||||
issues=issues,
|
||||
chamber=chamber,
|
||||
congress=congress,
|
||||
compare=compare,
|
||||
)
|
||||
return dashboard_url.replace("/dashboard", "/partials/dashboard", 1)
|
||||
|
||||
|
||||
def _toggle_compare(compare: list[int], legislator_id: int) -> list[int]:
|
||||
@@ -587,3 +592,18 @@ def _build_compare_url(
|
||||
if q:
|
||||
params.append(("q", q))
|
||||
return f"/compare?{urlencode(params, doseq=True)}" if params else "/compare"
|
||||
|
||||
|
||||
def _auth_context(current_user: auth.AuthSession | None) -> dict[str, Any]:
|
||||
"""Shared template context for auth-aware navigation."""
|
||||
return {
|
||||
"is_authenticated": current_user is not None,
|
||||
"is_admin": current_user.is_admin if current_user is not None else False,
|
||||
"current_user_name": current_user.display_name if current_user is not None else "",
|
||||
"current_user_email": current_user.email if current_user is not None else "",
|
||||
}
|
||||
|
||||
|
||||
def _delete_auth_cookie(response: Response) -> None:
|
||||
"""Delete the sealed WorkOS session cookie."""
|
||||
response.delete_cookie(getenv("WORKOS_SESSION_COOKIE_NAME", "workos_session"))
|
||||
|
||||
Reference in New Issue
Block a user