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
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.
91 lines
3.1 KiB
Python
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()
|