OAuth SSO infrastructure + account app support

- OAuthCode model + migration for authorization code flow
- OAuth client blueprint (auto-registered for non-federation apps)
- Per-app first-party session cookies (fixes Safari ITP)
- /oauth/authorize endpoint support in URL helpers
- account_url() helper + Jinja global
- Templates: federation_url('/auth/...') → account_url('/...')
- Widget registry: account page links use account_url

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 09:55:27 +00:00
parent 326b380135
commit 46f44f6171
14 changed files with 236 additions and 27 deletions

View File

@@ -0,0 +1,37 @@
"""Add oauth_codes table
Revision ID: p6n4k0l2m3
Revises: o5m3j9k1l2
Create Date: 2026-02-23
"""
from alembic import op
import sqlalchemy as sa
revision = "p6n4k0l2m3"
down_revision = "o5m3j9k1l2"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"oauth_codes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(128), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("client_id", sa.String(64), nullable=False),
sa.Column("redirect_uri", sa.String(512), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_oauth_code_code", "oauth_codes", ["code"], unique=True)
op.create_index("ix_oauth_code_user", "oauth_codes", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_oauth_code_user", table_name="oauth_codes")
op.drop_index("ix_oauth_code_code", table_name="oauth_codes")
op.drop_table("oauth_codes")

View File

@@ -1,5 +1,5 @@
{% import 'macros/links.html' as links %}
{% call links.link(federation_url('/auth/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% for link in account_nav_links %}

View File

@@ -1,6 +1,6 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ federation_url('/auth/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"

View File

@@ -22,7 +22,7 @@
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ federation_url('/auth/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(federation_url('/auth/account/'), hx_select_search ) %}
{% call links.link(account_url('/'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}

View File

@@ -1,5 +1,5 @@
{% set href=federation_url('/auth/account/') %}
{% set href=account_url('/') %}
<a
href="{{ href }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"

View File

@@ -1,5 +1,5 @@
{% set href=federation_url('/auth/account/') %}
{% set href=account_url('/') %}
<a
href="{{ href }}"
data-close-details

View File

@@ -11,7 +11,7 @@ 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"):
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models"):
try:
__import__(_mod)
except ImportError:
@@ -80,11 +80,10 @@ def create_base_app(
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777")
# Session cookie shared across subdomains
cookie_domain = os.getenv("SESSION_COOKIE_DOMAIN") # e.g. ".rose-ash.com"
if cookie_domain:
app.config["SESSION_COOKIE_DOMAIN"] = cookie_domain
app.config["SESSION_COOKIE_NAME"] = "blog_session"
# 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")
@@ -102,6 +101,11 @@ def create_base_app(
setup_jinja(app)
errors(app)
# Auto-register OAuth client blueprint for non-federation apps
if name != "federation":
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():

View File

@@ -13,7 +13,7 @@ from shared.browser.app.csrf import generate_csrf_token
from shared.browser.app.authz import has_access
from shared.browser.app.filters import register as register_filters
from .urls import blog_url, market_url, cart_url, events_url, federation_url, login_url, page_cart_url, market_product_url
from .urls import blog_url, market_url, cart_url, events_url, federation_url, account_url, login_url, page_cart_url, market_product_url
def setup_jinja(app: Quart) -> None:
@@ -98,6 +98,7 @@ def setup_jinja(app: Quart) -> None:
app.jinja_env.globals["cart_url"] = cart_url
app.jinja_env.globals["events_url"] = events_url
app.jinja_env.globals["federation_url"] = federation_url
app.jinja_env.globals["account_url"] = account_url
app.jinja_env.globals["login_url"] = login_url
app.jinja_env.globals["page_cart_url"] = page_cart_url
app.jinja_env.globals["market_product_url"] = market_product_url

130
infrastructure/oauth.py Normal file
View File

@@ -0,0 +1,130 @@
"""OAuth2 client blueprint for non-federation apps.
Each client app gets /auth/login, /auth/callback, /auth/logout.
Federation is the OAuth authorization server.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from quart import (
Blueprint,
redirect,
request,
session as qsession,
g,
current_app,
)
from sqlalchemy import select
from shared.db.session import get_session
from shared.models import User
from shared.models.oauth_code import OAuthCode
from shared.infrastructure.urls import federation_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity
from shared.events import emit_activity
SESSION_USER_KEY = "uid"
def create_oauth_blueprint(app_name: str) -> Blueprint:
"""Return an OAuth client blueprint for *app_name*."""
bp = Blueprint("oauth_auth", __name__, url_prefix="/auth")
@bp.get("/login")
@bp.get("/login/")
async def login():
next_url = request.args.get("next", "/")
state = secrets.token_urlsafe(32)
qsession["oauth_state"] = state
qsession["oauth_next"] = next_url
redirect_uri = app_url(app_name, "/auth/callback")
authorize_url = federation_url(
f"/oauth/authorize?client_id={app_name}"
f"&redirect_uri={redirect_uri}"
f"&state={state}"
)
return redirect(authorize_url)
@bp.get("/callback")
@bp.get("/callback/")
async def callback():
code = request.args.get("code")
state = request.args.get("state")
expected_state = qsession.pop("oauth_state", None)
next_url = qsession.pop("oauth_next", "/")
if not code or not state or state != expected_state:
current_app.logger.warning("OAuth callback: bad state or missing code")
return redirect("/")
expected_redirect = app_url(app_name, "/auth/callback")
now = datetime.now(timezone.utc)
async with get_session() as s:
async with s.begin():
result = await s.execute(
select(OAuthCode)
.where(OAuthCode.code == code)
.with_for_update()
)
oauth_code = result.scalar_one_or_none()
if not oauth_code:
current_app.logger.warning("OAuth callback: code not found")
return redirect("/")
if oauth_code.used_at is not None:
current_app.logger.warning("OAuth callback: code already used")
return redirect("/")
if oauth_code.expires_at < now:
current_app.logger.warning("OAuth callback: code expired")
return redirect("/")
if oauth_code.client_id != app_name:
current_app.logger.warning("OAuth callback: client_id mismatch")
return redirect("/")
if oauth_code.redirect_uri != expected_redirect:
current_app.logger.warning("OAuth callback: redirect_uri mismatch")
return redirect("/")
oauth_code.used_at = now
user_id = oauth_code.user_id
# Set local session
qsession[SESSION_USER_KEY] = user_id
# Emit login activity for cart adoption
ident = current_cart_identity()
anon_session_id = ident.get("session_id")
if anon_session_id:
try:
async with get_session() as s:
async with s.begin():
await emit_activity(
s,
activity_type="rose:Login",
actor_uri="internal:system",
object_type="Person",
object_data={
"user_id": user_id,
"session_id": anon_session_id,
},
)
except Exception:
current_app.logger.exception("OAuth: failed to emit login activity")
return redirect(next_url, 303)
@bp.post("/logout")
@bp.post("/logout/")
async def logout():
qsession.pop(SESSION_USER_KEY, None)
qsession.pop("cart_sid", None)
return redirect("/")
return bp

View File

@@ -41,6 +41,10 @@ def federation_url(path: str = "/") -> str:
return app_url("federation", path)
def account_url(path: str = "/") -> str:
return app_url("account", path)
def page_cart_url(page_slug: str, path: str = "/") -> str:
if not path.startswith("/"):
path = "/" + path
@@ -66,17 +70,24 @@ def market_product_url(product_slug: str, suffix: str = "", market_place=None) -
def login_url(next_url: str = "") -> str:
# Auth lives in federation. Set AUTH_APP to override.
from quart import session as qsession
auth_app = os.getenv("AUTH_APP", "federation")
base = app_url(auth_app, "/auth/login/")
from quart import current_app
# Federation handles login directly (magic link flow)
if current_app.name == "federation":
base = "/auth/login/"
params: list[str] = []
if next_url:
params.append(f"next={quote(next_url, safe='')}")
# Pass anonymous cart session so the auth app can adopt it on login
from quart import session as qsession
cart_sid = qsession.get("cart_sid")
if cart_sid:
params.append(f"cart_sid={quote(cart_sid, safe='')}")
if params:
return f"{base}?{'&'.join(params)}"
return base
# Client apps: local /auth/login triggers OAuth redirect to federation
base = "/auth/login/"
if next_url:
return f"{base}?next={quote(next_url, safe='')}"
return base

View File

@@ -1,6 +1,7 @@
from .user import User
from .kv import KV
from .magic_link import MagicLink
from .oauth_code import OAuthCode
from .menu_item import MenuItem
from .ghost_membership_entities import (

25
models/oauth_code.py Normal file
View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from shared.db.base import Base
class OAuthCode(Base):
__tablename__ = "oauth_codes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(128), unique=True, index=True, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
client_id: Mapped[str] = mapped_column(String(64), nullable=False)
redirect_uri: Mapped[str] = mapped_column(String(512), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
user = relationship("User", backref="oauth_codes")
__table_args__ = (
Index("ix_oauth_code_code", "code", unique=True),
Index("ix_oauth_code_user", "user_id"),
)

View File

@@ -48,8 +48,8 @@ class _WidgetRegistry:
slug = w.slug
def _href(s=slug):
from shared.infrastructure.urls import federation_url
return federation_url(f"/auth/{s}/")
from shared.infrastructure.urls import account_url
return account_url(f"/{s}/")
self._account_nav.append(AccountNavLink(
label=w.label,