Move auth server from federation to account
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s

Account is now the OAuth authorization server with magic link login,
OAuth2 authorize endpoint, SSO logout, and session management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-23 12:00:20 +00:00
parent 19189e6655
commit 2178607484
33 changed files with 723 additions and 2 deletions

12
app.py
View File

@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g from quart import g
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app from shared.infrastructure.factory import create_base_app
from shared.services.registry import services from shared.services.registry import services
from bp import register_account_bp from bp import register_account_bp, register_auth_bp
async def account_context() -> dict: async def account_context() -> dict:
@@ -39,7 +41,15 @@ def create_app() -> "Quart":
domain_services_fn=register_domain_services, domain_services_fn=register_domain_services,
) )
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
# --- blueprints --- # --- blueprints ---
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_account_bp()) app.register_blueprint(register_account_bp())
return app return app

0
blog/__init__.py Normal file
View File

14
blog/models/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag
# Shared models — canonical definitions live in shared/models/
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
from shared.models.menu_item import MenuItem
from shared.models.kv import KV
from shared.models.magic_link import MagicLink
from shared.models.user import User

View File

@@ -0,0 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike,
)

View File

@@ -0,0 +1,12 @@
# Re-export from canonical shared location
from shared.models.ghost_membership_entities import (
GhostLabel, UserLabel,
GhostNewsletter, UserNewsletter,
GhostTier, GhostSubscription,
)
__all__ = [
"GhostLabel", "UserLabel",
"GhostNewsletter", "UserNewsletter",
"GhostTier", "GhostSubscription",
]

4
blog/models/kv.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.kv import KV
__all__ = ["KV"]

View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.magic_link import MagicLink
__all__ = ["MagicLink"]

4
blog/models/menu_item.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.menu_item import MenuItem
__all__ = ["MenuItem"]

32
blog/models/snippet.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Snippet(Base):
__tablename__ = "snippets"
__table_args__ = (
UniqueConstraint("user_id", "name", name="uq_snippets_user_name"),
Index("ix_snippets_visibility", "visibility"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), nullable=False, default="private", server_default="private",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(),
)

52
blog/models/tag_group.py Normal file
View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import (
Integer,
String,
Text,
DateTime,
ForeignKey,
UniqueConstraint,
func,
)
from shared.db.base import Base
class TagGroup(Base):
__tablename__ = "tag_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
slug: Mapped[str] = mapped_column(String(191), unique=True, nullable=False)
feature_image: Mapped[Optional[str]] = mapped_column(Text())
colour: Mapped[Optional[str]] = mapped_column(String(32))
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
tag_links: Mapped[List["TagGroupTag"]] = relationship(
"TagGroupTag", back_populates="group", cascade="all, delete-orphan", passive_deletes=True
)
class TagGroupTag(Base):
__tablename__ = "tag_group_tags"
__table_args__ = (
UniqueConstraint("tag_group_id", "tag_id", name="uq_tag_group_tag"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tag_group_id: Mapped[int] = mapped_column(
ForeignKey("tag_groups.id", ondelete="CASCADE"), nullable=False
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tags.id", ondelete="CASCADE"), nullable=False
)
group: Mapped["TagGroup"] = relationship("TagGroup", back_populates="tag_links")

4
blog/models/user.py Normal file
View File

@@ -0,0 +1,4 @@
# Re-export from canonical shared location
from shared.models.user import User
__all__ = ["User"]

View File

@@ -1 +1,2 @@
from .account.routes import register as register_account_bp from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp

0
bp/auth/__init__.py Normal file
View File

232
bp/auth/routes.py Normal file
View File

@@ -0,0 +1,232 @@
"""Authentication routes for the account app.
Account is the OAuth authorization server. Owns magic link login/logout,
OAuth2 authorize endpoint, and SSO logout.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone, timedelta
from quart import (
Blueprint,
request,
render_template,
redirect,
url_for,
session as qsession,
g,
current_app,
)
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from shared.db.session import get_session
from shared.models import User
from shared.models.oauth_code import OAuthCode
from shared.infrastructure.urls import account_url, app_url
from shared.infrastructure.cart_identity import current_cart_identity
from shared.events import emit_activity
from .services import (
pop_login_redirect_target,
store_login_redirect_target,
send_magic_email,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
SESSION_USER_KEY = "uid"
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation"}
def register(url_prefix="/auth"):
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
# --- OAuth2 authorize endpoint -------------------------------------------
@auth_bp.get("/oauth/authorize")
@auth_bp.get("/oauth/authorize/")
async def oauth_authorize():
client_id = request.args.get("client_id", "")
redirect_uri = request.args.get("redirect_uri", "")
state = request.args.get("state", "")
if client_id not in ALLOWED_CLIENTS:
return "Invalid client_id", 400
expected_redirect = app_url(client_id, "/auth/callback")
if redirect_uri != expected_redirect:
return "Invalid redirect_uri", 400
# Not logged in — bounce to magic link login, then back here
if not g.get("user"):
# Preserve the full authorize URL so we return here after login
authorize_path = request.full_path # includes query string
store_login_redirect_target()
return redirect(url_for("auth.login_form", next=authorize_path))
# Logged in — issue authorization code
code = secrets.token_urlsafe(48)
now = datetime.now(timezone.utc)
expires = now + timedelta(minutes=5)
async with get_session() as s:
async with s.begin():
oauth_code = OAuthCode(
code=code,
user_id=g.user.id,
client_id=client_id,
redirect_uri=redirect_uri,
expires_at=expires,
)
s.add(oauth_code)
sep = "&" if "?" in redirect_uri else "?"
return redirect(f"{redirect_uri}{sep}code={code}&state={state}")
# --- Magic link login flow -----------------------------------------------
@auth_bp.get("/login/")
async def login_form():
store_login_redirect_target()
cross_cart_sid = request.args.get("cart_sid")
if cross_cart_sid:
qsession["cart_sid"] = cross_cart_sid
if g.get("user"):
# If there's a pending redirect (e.g. OAuth authorize), follow it
redirect_url = pop_login_redirect_target()
return redirect(redirect_url)
return await render_template("auth/login.html")
@auth_bp.post("/start/")
async def start_login():
form = await request.form
email_input = form.get("email") or ""
is_valid, email = validate_email(email_input)
if not is_valid:
return (
await render_template(
"auth/login.html",
error="Please enter a valid email address.",
email=email_input,
),
400,
)
user = await find_or_create_user(g.s, email)
token, expires = await create_magic_link(g.s, user.id)
from shared.utils import host_url
magic_url = host_url(url_for("auth.magic", token=token))
email_error = None
try:
await send_magic_email(email, magic_url)
except Exception as e:
current_app.logger.error("EMAIL SEND FAILED: %r", e)
email_error = (
"We couldn't send the email automatically. "
"Please try again in a moment."
)
return await render_template(
"auth/check_email.html",
email=email,
email_error=email_error,
)
@auth_bp.get("/magic/<token>/")
async def magic(token: str):
now = datetime.now(timezone.utc)
user_id: int | None = None
try:
async with get_session() as s:
async with s.begin():
user, error = await validate_magic_link(s, token)
if error:
return (
await render_template("auth/login.html", error=error),
400,
)
user_id = user.id
except Exception:
return (
await render_template(
"auth/login.html",
error="Could not sign you in right now. Please try again.",
),
502,
)
assert user_id is not None
ident = current_cart_identity()
anon_session_id = ident.get("session_id")
try:
async with get_session() as s:
async with s.begin():
u2 = await s.get(User, user_id)
if u2:
u2.last_login_at = now
if anon_session_id:
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 SQLAlchemyError:
current_app.logger.exception(
"[auth] non-fatal DB update for user_id=%s", user_id
)
qsession[SESSION_USER_KEY] = user_id
redirect_url = pop_login_redirect_target()
resp = redirect(redirect_url, 303)
resp.set_cookie(
"sso_hint", "1",
domain=".rose-ash.com", max_age=30 * 24 * 3600,
secure=True, samesite="Lax", httponly=True,
)
return resp
@auth_bp.post("/logout/")
async def logout():
qsession.pop(SESSION_USER_KEY, None)
resp = redirect(account_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/clear/")
async def clear():
"""One-time migration helper: clear all session cookies."""
qsession.clear()
resp = redirect(account_url("/"))
resp.delete_cookie("blog_session", domain=".rose-ash.com", path="/")
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
@auth_bp.get("/sso-logout/")
async def sso_logout():
"""SSO logout: clear account session + sso_hint, redirect to blog."""
qsession.pop(SESSION_USER_KEY, None)
from shared.infrastructure.urls import blog_url
resp = redirect(blog_url("/"))
resp.delete_cookie("sso_hint", domain=".rose-ash.com", path="/")
return resp
return auth_bp

View File

@@ -0,0 +1,24 @@
from .login_redirect import pop_login_redirect_target, store_login_redirect_target
from .auth_operations import (
get_app_host,
get_app_root,
send_magic_email,
load_user_by_id,
find_or_create_user,
create_magic_link,
validate_magic_link,
validate_email,
)
__all__ = [
"pop_login_redirect_target",
"store_login_redirect_target",
"get_app_host",
"get_app_root",
"send_magic_email",
"load_user_by_id",
"find_or_create_user",
"create_magic_link",
"validate_magic_link",
"validate_email",
]

View File

@@ -0,0 +1,156 @@
"""Auth operations for the account app.
Owns magic-link login. Shared models, shared config.
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from quart import current_app, render_template, request, g
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models import User, MagicLink
from shared.config import config
def get_app_host() -> str:
host = (
config().get("host") or os.getenv("APP_HOST") or "http://localhost:8000"
).rstrip("/")
return host
def get_app_root() -> str:
root = (g.root).rstrip("/")
return root
async def send_magic_email(to_email: str, link_url: str) -> None:
host = os.getenv("SMTP_HOST")
port = int(os.getenv("SMTP_PORT") or "587")
username = os.getenv("SMTP_USER")
password = os.getenv("SMTP_PASS")
mail_from = os.getenv("MAIL_FROM") or "no-reply@example.com"
site_name = config().get("title", "Rose Ash")
subject = f"Your sign-in link \u2014 {site_name}"
tpl_vars = dict(site_name=site_name, link_url=link_url)
text_body = await render_template("_email/magic_link.txt", **tpl_vars)
html_body = await render_template("_email/magic_link.html", **tpl_vars)
if not host or not username or not password:
current_app.logger.warning(
"SMTP not configured. Printing magic link to console for %s: %s",
to_email,
link_url,
)
print(f"[DEV] Magic link for {to_email}: {link_url}")
return
import aiosmtplib
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = mail_from
msg["To"] = to_email
msg["Subject"] = subject
msg.set_content(text_body)
msg.add_alternative(html_body, subtype="html")
is_secure = port == 465
if is_secure:
smtp = aiosmtplib.SMTP(
hostname=host, port=port, use_tls=True,
username=username, password=password,
)
else:
smtp = aiosmtplib.SMTP(
hostname=host, port=port, start_tls=True,
username=username, password=password,
)
async with smtp:
await smtp.send_message(msg)
async def load_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
stmt = (
select(User)
.options(selectinload(User.labels))
.where(User.id == user_id)
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def find_or_create_user(session: AsyncSession, email: str) -> User:
result = await session.execute(select(User).where(User.email == email))
user = result.scalar_one_or_none()
if user is None:
user = User(email=email)
session.add(user)
await session.flush()
return user
async def create_magic_link(
session: AsyncSession,
user_id: int,
purpose: str = "signin",
expires_minutes: int = 15,
) -> Tuple[str, datetime]:
token = secrets.token_urlsafe(32)
expires = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
ml = MagicLink(
token=token,
user_id=user_id,
purpose=purpose,
expires_at=expires,
ip=request.headers.get("x-forwarded-for", request.remote_addr),
user_agent=request.headers.get("user-agent"),
)
session.add(ml)
return token, expires
async def validate_magic_link(
session: AsyncSession,
token: str,
) -> Tuple[Optional[User], Optional[str]]:
now = datetime.now(timezone.utc)
ml = await session.scalar(
select(MagicLink)
.where(MagicLink.token == token)
.with_for_update()
)
if not ml or ml.purpose != "signin":
return None, "Invalid or expired link."
if ml.used_at or ml.expires_at < now:
return None, "This link has expired. Please request a new one."
user = await session.get(User, ml.user_id)
if not user:
return None, "User not found."
ml.used_at = now
return user, None
def validate_email(email: str) -> Tuple[bool, str]:
email = email.strip().lower()
if not email or "@" not in email:
return False, email
return True, email

View File

@@ -0,0 +1,45 @@
from urllib.parse import urlparse
from quart import session
from shared.infrastructure.urls import account_url
LOGIN_REDIRECT_SESSION_KEY = "login_redirect_to"
def store_login_redirect_target() -> None:
from quart import request
target = request.args.get("next")
if not target:
ref = request.referrer or ""
try:
parsed = urlparse(ref)
target = parsed.path or ""
except Exception:
target = ""
if not target:
return
# Accept both relative paths and absolute URLs (cross-app redirects)
if target.startswith("http://") or target.startswith("https://"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
elif target.startswith("/") and not target.startswith("//"):
session[LOGIN_REDIRECT_SESSION_KEY] = target
def pop_login_redirect_target() -> str:
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
if not path or not isinstance(path, str):
return account_url("/")
# Absolute URL: return as-is (cross-app redirect)
if path.startswith("http://") or path.startswith("https://"):
return path
# Relative path: must start with / and not //
if path.startswith("/") and not path.startswith("//"):
return account_url(path)
return account_url("/")

0
cart/__init__.py Normal file
View File

2
cart/models/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .order import Order, OrderItem
from .page_config import PageConfig

1
cart/models/order.py Normal file
View File

@@ -0,0 +1 @@
from shared.models.order import Order, OrderItem # noqa: F401

View File

@@ -0,0 +1 @@
from shared.models.page_config import PageConfig # noqa: F401

0
events/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
from .calendars import (
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

View File

@@ -0,0 +1,4 @@
from shared.models.calendars import ( # noqa: F401
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

0
market/__init__.py Normal file
View File

View File

@@ -0,0 +1,8 @@
from .market import (
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)
from .market_place import MarketPlace

7
market/models/market.py Normal file
View File

@@ -0,0 +1,7 @@
from shared.models.market import ( # noqa: F401
Product, ProductLike, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
CartItem,
)

View File

@@ -0,0 +1 @@
from shared.models.market_place import MarketPlace # noqa: F401

2
shared

Submodule shared updated: 60cd08adc9...dfc41ada7d

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#f5f5f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f4;padding:40px 0;">
<tr><td align="center">
<table width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:12px;border:1px solid #e7e5e4;padding:40px;">
<tr><td>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#1c1917;">{{ site_name }}</h1>
<p style="margin:0 0 24px;font-size:15px;color:#57534e;">Sign in to your account</p>
<p style="margin:0 0 24px;font-size:15px;line-height:1.5;color:#44403c;">
Click the button below to sign in. This link will expire in 15&nbsp;minutes.
</p>
<table cellpadding="0" cellspacing="0" style="margin:0 0 24px;"><tr><td style="border-radius:8px;background:#1c1917;">
<a href="{{ link_url }}" target="_blank"
style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:500;color:#ffffff;text-decoration:none;border-radius:8px;">
Sign in
</a>
</td></tr></table>
<p style="margin:0 0 8px;font-size:13px;color:#78716c;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 24px;font-size:13px;word-break:break-all;">
<a href="{{ link_url }}" style="color:#1c1917;">{{ link_url }}</a>
</p>
<hr style="border:none;border-top:1px solid #e7e5e4;margin:24px 0;">
<p style="margin:0;font-size:12px;color:#a8a29e;">
If you did not request this email, you can safely ignore it.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,8 @@
Hello,
Click this link to sign in:
{{ link_url }}
This link will expire in 15 minutes.
If you did not request this, you can ignore this email.

View File

@@ -0,0 +1,19 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Check your email — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto text-center">
<h1 class="text-2xl font-bold mb-4">Check your email</h1>
<p class="text-stone-600 mb-2">
We sent a sign-in link to <strong>{{ email }}</strong>.
</p>
<p class="text-stone-500 text-sm">
Click the link in the email to sign in. The link expires in 15 minutes.
</p>
{% if email_error %}
<div class="bg-yellow-50 border border-yellow-200 text-yellow-700 p-3 rounded mt-4">
{{ email_error }}
</div>
{% endif %}
</div>
{% endblock %}

36
templates/auth/login.html Normal file
View File

@@ -0,0 +1,36 @@
{% extends "_types/root/_index.html" %}
{% block meta %}{% endblock %}
{% block title %}Login — Rose Ash{% endblock %}
{% block content %}
<div class="py-8 max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-6">Sign in</h1>
{% if error %}
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded mb-4">
{{ error }}
</div>
{% endif %}
<form method="post" action="{{ url_for('auth.start_login') }}" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium mb-1">Email address</label>
<input
type="email"
name="email"
id="email"
value="{{ email | default('') }}"
required
autofocus
class="w-full border border-stone-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
>
</div>
<button
type="submit"
class="w-full bg-stone-800 text-white py-2 px-4 rounded hover:bg-stone-700 transition"
>
Send magic link
</button>
</form>
</div>
{% endblock %}