thermograph/backend/notifications/discord_link.py
Emi Griffith c17a4c3dd7
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 8s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Successful in 1m1s
PR build (required check) / build-backend (pull_request) Successful in 1m29s
PR build (required check) / gate (pull_request) Successful in 1s
accounts: sign in with Google, on a shared provider-agnostic OAuth engine
Adds Google as a second identity provider. Rather than a second copy of the
Discord flow, the flow itself moves to accounts/oauth.py and both providers
become configurations of it — account resolution is the security-critical
part, and a parallel hand-rolled copy is where a subtle divergence becomes a
takeover. A third provider is now a PROVIDERS entry and nothing else.

Identity moves to an oauth_account table keyed (provider, subject), so a
login resolves on the provider's own stable id rather than an email that can
be changed or reassigned. user.discord_id deliberately stays: it is the DM
delivery address notify.py reads, not merely an identity, and the Discord
link keeps writing it. discord_only becomes oauth_only, and the lockout guard
now refuses to unlink the *last* provider from a password-less account rather
than singling out Discord — with two linked, either may go.

Migration 0005 renames the flag and backfills an oauth_account row for every
existing discord_id. Without that backfill an already-linked user would stop
being recognised at login and would silently get a second account on their
next sign-in. It also drops a vestigial discord_only that 0003 re-adds on a
database whose 0001 already built oauth_only from current metadata, which
otherwise left fresh and upgraded databases with different schemas.

Compatibility, since the frontend deploys independently of this service:
/discord/link/callback keeps answering because that URL is registered in
Discord's developer portal, the other /discord/* routes stay because the
deployed frontend calls them, the callback still emits ?discord= alongside
?oauth=, and UserRead still carries discord_only as a computed alias.

Google needs THERMOGRAPH_GOOGLE_CLIENT_ID/_CLIENT_SECRET and its own
registered redirect URI; unset, it reports disabled and shows no UI.
2026-07-26 12:06:56 -07:00

91 lines
3.1 KiB
Python

"""Discord's legacy /discord/* auth routes, plus the DM opt-in toggle.
The OAuth flow itself moved to ``accounts/oauth.py`` when Google was added — this
module holds no auth logic, only the paths that have to keep answering:
* **/discord/link/callback is registered in Discord's developer portal.** Changing
it would need an operator to edit the portal before any Discord login worked, so
it stays exactly where it is and forwards to the shared handler.
* The other /discord/* routes are what the currently-deployed frontend calls.
Frontend and backend deploy independently (see the root CLAUDE.md), so a newer
backend must keep serving an older frontend. They can go once no deployed
frontend predates /oauth/*.
``POST /discord/dm`` is not legacy and is not moving: it toggles alert delivery,
which is a notifications concern, not an authentication one.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from accounts import oauth
from accounts.db import get_async_session
from accounts.models import User
from accounts.users import (
current_active_user,
current_user_optional,
get_access_token_db,
get_user_manager,
)
router = APIRouter(tags=["discord"])
@router.get("/discord/config")
async def discord_config():
"""Superseded by /oauth/config, which reports every provider. Kept because the
deployed frontend asks this one."""
return {"enabled": oauth.enabled("discord")}
@router.get("/discord/link/start")
async def link_start(request: Request, user=Depends(current_active_user)):
return await oauth.link_start("discord", request, user)
@router.get("/discord/login/start")
async def login_start(request: Request, user=Depends(current_user_optional)):
return await oauth.login_start("discord", request, user)
@router.get("/discord/link/callback")
async def link_callback(
request: Request,
user=Depends(current_user_optional),
session: AsyncSession = Depends(get_async_session),
user_manager=Depends(get_user_manager),
access_token_db=Depends(get_access_token_db),
):
"""The URL registered in Discord's portal. Both Discord flows land here."""
return await oauth._callback("discord", request, user, session, user_manager,
access_token_db)
@router.post("/discord/unlink", status_code=204)
async def unlink(
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
await oauth._unlink("discord", user, session)
class DmToggle(BaseModel):
enabled: bool
@router.post("/discord/dm", status_code=204)
async def set_dm(
payload: DmToggle,
user=Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Turn Discord DM alerts on or off without unlinking. A no-op unless a Discord
account is actually linked."""
await session.execute(
update(User).where(User.id == user.id, User.discord_id.is_not(None))
.values(discord_dm=payload.enabled)
)
await session.commit()