diff --git a/bp/auth/routes.py b/bp/auth/routes.py index 34f388c..0277f9c 100644 --- a/bp/auth/routes.py +++ b/bp/auth/routes.py @@ -1,17 +1,17 @@ """Authentication routes for the federation app. -Ported from blog/bp/auth/routes.py — owns magic link login/logout -plus the OOB account page system (newsletters, widget pages). +Owns magic link login/logout + OAuth2 authorization server endpoint. +Account pages (newsletters, widget pages) have moved to the account app. """ from __future__ import annotations -from datetime import datetime, timezone +import secrets +from datetime import datetime, timezone, timedelta from quart import ( Blueprint, request, render_template, - make_response, redirect, url_for, session as qsession, @@ -22,15 +22,11 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from shared.db.session import get_session -from shared.models import User, UserNewsletter -from shared.models.ghost_membership_entities import GhostNewsletter -from shared.services.widget_registry import widgets -from shared.config import config -from shared.utils import host_url -from shared.infrastructure.urls import federation_url +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 -from shared.services.registry import services from .services import ( pop_login_redirect_target, @@ -44,24 +40,55 @@ from .services import ( SESSION_USER_KEY = "uid" -oob = { - "oob_extends": "oob_elements.html", - "extends": "_types/root/_index.html", - "parent_id": "root-header-child", - "child_id": "auth-header-child", - "header": "_types/auth/header/_header.html", - "parent_header": "_types/root/header/_header.html", - "nav": "_types/auth/_nav.html", - "main": "_types/auth/_main_panel.html", -} +ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "account"} def register(url_prefix="/auth"): auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix) - @auth_bp.context_processor - def context(): - return {"oob": oob, "account_nav_links": widgets.account_nav} + # --- 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(): @@ -70,98 +97,11 @@ def register(url_prefix="/auth"): if cross_cart_sid: qsession["cart_sid"] = cross_cart_sid if g.get("user"): - return redirect(federation_url("/")) + # 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.get("/account/") - async def account(): - from shared.browser.app.utils.htmx import is_htmx_request - - if not g.get("user"): - return redirect(host_url(url_for("auth.login_form"))) - - if not is_htmx_request(): - html = await render_template("_types/auth/index.html") - else: - html = await render_template("_types/auth/_oob_elements.html") - - return await make_response(html) - - @auth_bp.get("/newsletters/") - async def newsletters(): - from shared.browser.app.utils.htmx import is_htmx_request - - if not g.get("user"): - return redirect(host_url(url_for("auth.login_form"))) - - result = await g.s.execute( - select(GhostNewsletter).order_by(GhostNewsletter.name) - ) - all_newsletters = result.scalars().all() - - sub_result = await g.s.execute( - select(UserNewsletter).where( - UserNewsletter.user_id == g.user.id, - ) - ) - user_subs = {un.newsletter_id: un for un in sub_result.scalars().all()} - - newsletter_list = [] - for nl in all_newsletters: - un = user_subs.get(nl.id) - newsletter_list.append({ - "newsletter": nl, - "un": un, - "subscribed": un.subscribed if un else False, - }) - - nl_oob = {**oob, "main": "_types/auth/_newsletters_panel.html"} - - if not is_htmx_request(): - html = await render_template( - "_types/auth/index.html", - oob=nl_oob, - newsletter_list=newsletter_list, - ) - else: - html = await render_template( - "_types/auth/_oob_elements.html", - oob=nl_oob, - newsletter_list=newsletter_list, - ) - - return await make_response(html) - - @auth_bp.post("/newsletter//toggle/") - async def toggle_newsletter(newsletter_id: int): - if not g.get("user"): - return "", 401 - - result = await g.s.execute( - select(UserNewsletter).where( - UserNewsletter.user_id == g.user.id, - UserNewsletter.newsletter_id == newsletter_id, - ) - ) - un = result.scalar_one_or_none() - - if un: - un.subscribed = not un.subscribed - else: - un = UserNewsletter( - user_id=g.user.id, - newsletter_id=newsletter_id, - subscribed=True, - ) - g.s.add(un) - - await g.s.flush() - - return await render_template( - "_types/auth/_newsletter_toggle.html", - un=un, - ) - @auth_bp.post("/start/") async def start_login(): form = await request.form @@ -181,6 +121,7 @@ def register(url_prefix="/auth"): 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 @@ -262,35 +203,4 @@ def register(url_prefix="/auth"): qsession.pop(SESSION_USER_KEY, None) return redirect(federation_url("/")) - # Catch-all for widget pages — must be last to avoid shadowing specific routes - @auth_bp.get("//") - async def widget_page(slug): - from shared.browser.app.utils.htmx import is_htmx_request - from quart import abort - - widget = widgets.account_page_by_slug(slug) - if not widget: - abort(404) - - if not g.get("user"): - return redirect(host_url(url_for("auth.login_form"))) - - ctx = await widget.context_fn(g.s, user_id=g.user.id) - w_oob = {**oob, "main": widget.template} - - if not is_htmx_request(): - html = await render_template( - "_types/auth/index.html", - oob=w_oob, - **ctx, - ) - else: - html = await render_template( - "_types/auth/_oob_elements.html", - oob=w_oob, - **ctx, - ) - - return await make_response(html) - return auth_bp diff --git a/bp/auth/services/login_redirect.py b/bp/auth/services/login_redirect.py index acf6df7..aff43d9 100644 --- a/bp/auth/services/login_redirect.py +++ b/bp/auth/services/login_redirect.py @@ -32,7 +32,7 @@ def store_login_redirect_target() -> None: def pop_login_redirect_target() -> str: path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None) if not path or not isinstance(path, str): - return federation_url("/auth/") + return federation_url("/") # Absolute URL: return as-is (cross-app redirect) if path.startswith("http://") or path.startswith("https://"): @@ -42,4 +42,4 @@ def pop_login_redirect_target() -> str: if path.startswith("/") and not path.startswith("//"): return federation_url(path) - return federation_url("/auth/") + return federation_url("/")