Compare commits
1 Commits
326b380135
...
46f44f6171
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f44f6171 |
37
alembic/versions/p6n4k0l2m3_add_oauth_codes_table.py
Normal file
37
alembic/versions/p6n4k0l2m3_add_oauth_codes_table.py
Normal 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")
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
{% set href=federation_url('/auth/account/') %}
|
||||
{% set href=account_url('/') %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
data-close-details
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
130
infrastructure/oauth.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
25
models/oauth_code.py
Normal 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"),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user