Add OAuth authorize endpoint, move account routes to account app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
- /oauth/authorize: validates client_id, redirect_uri, issues auth codes - Remove account/newsletters/widget routes (now in account microservice) - Default post-login redirect: federation home instead of /auth/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,17 @@
|
|||||||
"""Authentication routes for the federation app.
|
"""Authentication routes for the federation app.
|
||||||
|
|
||||||
Ported from blog/bp/auth/routes.py — owns magic link login/logout
|
Owns magic link login/logout + OAuth2 authorization server endpoint.
|
||||||
plus the OOB account page system (newsletters, widget pages).
|
Account pages (newsletters, widget pages) have moved to the account app.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
import secrets
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
request,
|
request,
|
||||||
render_template,
|
render_template,
|
||||||
make_response,
|
|
||||||
redirect,
|
redirect,
|
||||||
url_for,
|
url_for,
|
||||||
session as qsession,
|
session as qsession,
|
||||||
@@ -22,15 +22,11 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from shared.db.session import get_session
|
from shared.db.session import get_session
|
||||||
from shared.models import User, UserNewsletter
|
from shared.models import User
|
||||||
from shared.models.ghost_membership_entities import GhostNewsletter
|
from shared.models.oauth_code import OAuthCode
|
||||||
from shared.services.widget_registry import widgets
|
from shared.infrastructure.urls import federation_url, app_url
|
||||||
from shared.config import config
|
|
||||||
from shared.utils import host_url
|
|
||||||
from shared.infrastructure.urls import federation_url
|
|
||||||
from shared.infrastructure.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from shared.events import emit_activity
|
from shared.events import emit_activity
|
||||||
from shared.services.registry import services
|
|
||||||
|
|
||||||
from .services import (
|
from .services import (
|
||||||
pop_login_redirect_target,
|
pop_login_redirect_target,
|
||||||
@@ -44,24 +40,55 @@ from .services import (
|
|||||||
|
|
||||||
SESSION_USER_KEY = "uid"
|
SESSION_USER_KEY = "uid"
|
||||||
|
|
||||||
oob = {
|
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "account"}
|
||||||
"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",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix="/auth"):
|
def register(url_prefix="/auth"):
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
|
auth_bp = Blueprint("auth", __name__, url_prefix=url_prefix)
|
||||||
|
|
||||||
@auth_bp.context_processor
|
# --- OAuth2 authorize endpoint -------------------------------------------
|
||||||
def context():
|
|
||||||
return {"oob": oob, "account_nav_links": widgets.account_nav}
|
@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/")
|
@auth_bp.get("/login/")
|
||||||
async def login_form():
|
async def login_form():
|
||||||
@@ -70,98 +97,11 @@ def register(url_prefix="/auth"):
|
|||||||
if cross_cart_sid:
|
if cross_cart_sid:
|
||||||
qsession["cart_sid"] = cross_cart_sid
|
qsession["cart_sid"] = cross_cart_sid
|
||||||
if g.get("user"):
|
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")
|
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/<int:newsletter_id>/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/")
|
@auth_bp.post("/start/")
|
||||||
async def start_login():
|
async def start_login():
|
||||||
form = await request.form
|
form = await request.form
|
||||||
@@ -181,6 +121,7 @@ def register(url_prefix="/auth"):
|
|||||||
user = await find_or_create_user(g.s, email)
|
user = await find_or_create_user(g.s, email)
|
||||||
token, expires = await create_magic_link(g.s, user.id)
|
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))
|
magic_url = host_url(url_for("auth.magic", token=token))
|
||||||
|
|
||||||
email_error = None
|
email_error = None
|
||||||
@@ -262,35 +203,4 @@ def register(url_prefix="/auth"):
|
|||||||
qsession.pop(SESSION_USER_KEY, None)
|
qsession.pop(SESSION_USER_KEY, None)
|
||||||
return redirect(federation_url("/"))
|
return redirect(federation_url("/"))
|
||||||
|
|
||||||
# Catch-all for widget pages — must be last to avoid shadowing specific routes
|
|
||||||
@auth_bp.get("/<slug>/")
|
|
||||||
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
|
return auth_bp
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def store_login_redirect_target() -> None:
|
|||||||
def pop_login_redirect_target() -> str:
|
def pop_login_redirect_target() -> str:
|
||||||
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
|
path = session.pop(LOGIN_REDIRECT_SESSION_KEY, None)
|
||||||
if not path or not isinstance(path, str):
|
if not path or not isinstance(path, str):
|
||||||
return federation_url("/auth/")
|
return federation_url("/")
|
||||||
|
|
||||||
# Absolute URL: return as-is (cross-app redirect)
|
# Absolute URL: return as-is (cross-app redirect)
|
||||||
if path.startswith("http://") or path.startswith("https://"):
|
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("//"):
|
if path.startswith("/") and not path.startswith("//"):
|
||||||
return federation_url(path)
|
return federation_url(path)
|
||||||
|
|
||||||
return federation_url("/auth/")
|
return federation_url("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user