This repository has been archived on 2026-02-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shared/infrastructure/factory.py
giles 6bb26522a1 Add OAuth grants for per-device session revocation
- OAuthGrant model tracks each client authorization, tied to the
  account session (issuer_session) that issued it
- OAuth authorize creates grant + code together
- Client apps store grant_token in session, verify via account's
  internal /auth/internal/verify-grant endpoint (Redis-cached 60s)
- Account logout revokes only grants from that device's session
- Replaces iframe-based logout with server-side grant revocation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:08 +00:00

229 lines
7.6 KiB
Python

from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Callable, Awaitable, Sequence
from quart import Quart, request, g, redirect, send_from_directory
from shared.config import init_config, config, pretty
from shared.models import KV # ensure shared models imported
# Register all app model classes with SQLAlchemy so cross-domain
# relationship() string references resolve correctly.
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models"):
try:
__import__(_mod)
except ImportError:
pass
from shared.log_config import configure_logging
from shared.events import EventProcessor
from shared.db.session import register_db
from shared.browser.app.middleware import register as register_middleware
from shared.browser.app.redis_cacher import register as register_redis
from shared.browser.app.csrf import protect
from shared.browser.app.errors import errors
from .jinja_setup import setup_jinja
from .user_loader import load_current_user
# Async init of config (runs once at import)
asyncio.run(init_config())
BASE_DIR = Path(__file__).resolve().parent.parent
STATIC_DIR = str(BASE_DIR / "static")
TEMPLATE_DIR = str(BASE_DIR / "browser" / "templates")
def create_base_app(
name: str,
*,
context_fn: Callable[[], Awaitable[dict]] | None = None,
before_request_fns: Sequence[Callable[[], Awaitable[None]]] | None = None,
domain_services_fn: Callable[[], None] | None = None,
) -> Quart:
"""
Create a Quart app with shared infrastructure.
Parameters
----------
name:
Application name (also used as CACHE_APP_PREFIX).
context_fn:
Async function returning a dict for template context.
Each app provides its own — the cart app queries locally,
while blog/market apps fetch via internal API.
If not provided, a minimal default context is used.
before_request_fns:
Extra before-request hooks (e.g. cart_loader for the cart app).
domain_services_fn:
Callable that registers domain services on the shared registry.
Each app provides its own — registering real impls for owned
domains and stubs (or real impls) for others.
"""
if domain_services_fn is not None:
domain_services_fn()
from shared.services.widgets import register_all_widgets
register_all_widgets()
app = Quart(
name,
static_folder=STATIC_DIR,
static_url_path="/static",
template_folder=TEMPLATE_DIR,
)
configure_logging(name)
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777")
# Per-app first-party session cookie (no shared domain — avoids Safari ITP)
app.config["SESSION_COOKIE_NAME"] = f"{name}_session"
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = True
# Ghost / Redis config
app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL")
app.config["GHOST_PUBLIC_URL"] = os.getenv("GHOST_PUBLIC_URL")
app.config["GHOST_CONTENT_KEY"] = os.getenv("GHOST_CONTENT_API_KEY")
app.config["REDIS_URL"] = os.getenv("REDIS_URL")
# Cache app prefix for key namespacing
app.config["CACHE_APP_PREFIX"] = name
# --- infrastructure ---
register_middleware(app)
register_db(app)
register_redis(app)
setup_jinja(app)
errors(app)
# Auto-register OAuth client blueprint for non-account apps
# (account is the OAuth authorization server)
if name != "account":
from shared.infrastructure.oauth import create_oauth_blueprint
app.register_blueprint(create_oauth_blueprint(name))
# --- before-request hooks ---
@app.before_request
async def _route_log():
g.root = request.headers.get("x-forwarded-prefix", "/")
g.scheme = request.scheme
g.host = request.host
@app.before_request
async def _load_user():
await load_current_user()
# Register any app-specific before-request hooks (e.g. cart loader)
if before_request_fns:
for fn in before_request_fns:
app.before_request(fn)
# Grant revocation check: client apps verify their grant is still valid
if name != "account":
@app.before_request
async def _check_grant():
from quart import session as qs
grant_token = qs.get("grant_token")
if not grant_token or not qs.get("uid"):
return
if request.path.startswith("/auth/"):
return
# Check Redis cache first (avoid hammering account on every request)
cache_key = f"grant:{grant_token}"
from shared.browser.app.redis_cacher import get_redis
redis = get_redis()
if redis:
cached = await redis.get(cache_key)
if cached == b"ok":
return
if cached == b"revoked":
qs.pop("uid", None)
qs.pop("grant_token", None)
qs.pop("cart_sid", None)
return
# Call account's internal endpoint
import os, aiohttp
account_internal = (os.getenv("INTERNAL_URL_ACCOUNT") or "http://account:8000").rstrip("/")
try:
async with aiohttp.ClientSession() as http:
async with http.get(
f"{account_internal}/auth/internal/verify-grant",
params={"token": grant_token},
timeout=aiohttp.ClientTimeout(total=3),
) as resp:
data = await resp.json()
valid = data.get("valid", False)
except Exception:
# If account is unreachable, don't log user out
return
if redis:
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
if not valid:
qs.pop("uid", None)
qs.pop("grant_token", None)
qs.pop("cart_sid", None)
@app.before_request
async def _csrf_protect():
await protect()
# --- after-request hooks ---
# Clear old shared-domain session cookie (migration from .rose-ash.com)
@app.after_request
async def _clear_old_shared_cookie(response):
if request.cookies.get("blog_session"):
response.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
return response
@app.after_request
async def _add_hx_preserve_search_header(response):
value = request.headers.get("X-Search")
if value is not None:
response.headers["HX-Preserve-Search"] = value
return response
# --- context processor ---
if context_fn is not None:
@app.context_processor
async def _inject_base():
return await context_fn()
else:
# Minimal fallback (no cart, no menu_items)
from .context import base_context
@app.context_processor
async def _inject_base():
return await base_context()
# --- event processor ---
_event_processor = EventProcessor(app_name=name)
# --- startup ---
@app.before_serving
async def _startup():
from shared.events.handlers import register_shared_handlers
register_shared_handlers()
await init_config()
print(pretty())
await _event_processor.start()
@app.after_serving
async def _stop_event_processor():
await _event_processor.stop()
# --- favicon ---
@app.get("/favicon.ico")
async def favicon():
return await send_from_directory("static", "favicon.ico")
return app