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 %}
|
{% 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
|
newsletters
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% for link in account_nav_links %}
|
{% for link in account_nav_links %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
|
||||||
<button
|
<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-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||||
hx-target="#nl-{{ un.newsletter_id }}"
|
hx-target="#nl-{{ un.newsletter_id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{# No subscription row yet — show an off toggle that will create one #}
|
{# No subscription row yet — show an off toggle that will create one #}
|
||||||
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
|
||||||
<button
|
<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-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
|
||||||
hx-target="#nl-{{ item.newsletter.id }}"
|
hx-target="#nl-{{ item.newsletter.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='auth-row', oob=oob) %}
|
{% 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>
|
<i class="fa-solid fa-user"></i>
|
||||||
<div>account</div>
|
<div>account</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
{% set href=federation_url('/auth/account/') %}
|
{% set href=account_url('/') %}
|
||||||
<a
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
|
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
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
data-close-details
|
data-close-details
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from shared.config import init_config, config, pretty
|
|||||||
from shared.models import KV # ensure shared models imported
|
from shared.models import KV # ensure shared models imported
|
||||||
# Register all app model classes with SQLAlchemy so cross-domain
|
# Register all app model classes with SQLAlchemy so cross-domain
|
||||||
# relationship() string references resolve correctly.
|
# 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:
|
try:
|
||||||
__import__(_mod)
|
__import__(_mod)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -80,11 +80,10 @@ def create_base_app(
|
|||||||
|
|
||||||
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777")
|
app.secret_key = os.getenv("SECRET_KEY", "dev-secret-key-change-me-777")
|
||||||
|
|
||||||
# Session cookie shared across subdomains
|
# Per-app first-party session cookie (no shared domain — avoids Safari ITP)
|
||||||
cookie_domain = os.getenv("SESSION_COOKIE_DOMAIN") # e.g. ".rose-ash.com"
|
app.config["SESSION_COOKIE_NAME"] = f"{name}_session"
|
||||||
if cookie_domain:
|
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||||
app.config["SESSION_COOKIE_DOMAIN"] = cookie_domain
|
app.config["SESSION_COOKIE_SECURE"] = True
|
||||||
app.config["SESSION_COOKIE_NAME"] = "blog_session"
|
|
||||||
|
|
||||||
# Ghost / Redis config
|
# Ghost / Redis config
|
||||||
app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL")
|
app.config["GHOST_API_URL"] = os.getenv("GHOST_API_URL")
|
||||||
@@ -102,6 +101,11 @@ def create_base_app(
|
|||||||
setup_jinja(app)
|
setup_jinja(app)
|
||||||
errors(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 ---
|
# --- before-request hooks ---
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def _route_log():
|
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.authz import has_access
|
||||||
from shared.browser.app.filters import register as register_filters
|
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:
|
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["cart_url"] = cart_url
|
||||||
app.jinja_env.globals["events_url"] = events_url
|
app.jinja_env.globals["events_url"] = events_url
|
||||||
app.jinja_env.globals["federation_url"] = federation_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["login_url"] = login_url
|
||||||
app.jinja_env.globals["page_cart_url"] = page_cart_url
|
app.jinja_env.globals["page_cart_url"] = page_cart_url
|
||||||
app.jinja_env.globals["market_product_url"] = market_product_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)
|
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:
|
def page_cart_url(page_slug: str, path: str = "/") -> str:
|
||||||
if not path.startswith("/"):
|
if not path.startswith("/"):
|
||||||
path = "/" + path
|
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:
|
def login_url(next_url: str = "") -> str:
|
||||||
# Auth lives in federation. Set AUTH_APP to override.
|
from quart import current_app
|
||||||
from quart import session as qsession
|
|
||||||
auth_app = os.getenv("AUTH_APP", "federation")
|
# Federation handles login directly (magic link flow)
|
||||||
base = app_url(auth_app, "/auth/login/")
|
if current_app.name == "federation":
|
||||||
params: list[str] = []
|
base = "/auth/login/"
|
||||||
|
params: list[str] = []
|
||||||
|
if next_url:
|
||||||
|
params.append(f"next={quote(next_url, safe='')}")
|
||||||
|
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:
|
if next_url:
|
||||||
params.append(f"next={quote(next_url, safe='')}")
|
return f"{base}?next={quote(next_url, safe='')}"
|
||||||
# Pass anonymous cart session so the auth app can adopt it on login
|
|
||||||
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
|
return base
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from .user import User
|
from .user import User
|
||||||
from .kv import KV
|
from .kv import KV
|
||||||
from .magic_link import MagicLink
|
from .magic_link import MagicLink
|
||||||
|
from .oauth_code import OAuthCode
|
||||||
from .menu_item import MenuItem
|
from .menu_item import MenuItem
|
||||||
|
|
||||||
from .ghost_membership_entities import (
|
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
|
slug = w.slug
|
||||||
|
|
||||||
def _href(s=slug):
|
def _href(s=slug):
|
||||||
from shared.infrastructure.urls import federation_url
|
from shared.infrastructure.urls import account_url
|
||||||
return federation_url(f"/auth/{s}/")
|
return account_url(f"/{s}/")
|
||||||
|
|
||||||
self._account_nav.append(AccountNavLink(
|
self._account_nav.append(AccountNavLink(
|
||||||
label=w.label,
|
label=w.label,
|
||||||
|
|||||||
Reference in New Issue
Block a user