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.
528 lines
23 KiB
Python
528 lines
23 KiB
Python
"""Sign in with an external provider, and connect one to an existing account.
|
|
|
|
One engine, two providers (Discord, Google). Adding a third means adding a
|
|
``Provider`` entry and nothing else — the flow, the signed state, the account
|
|
resolution and the session issuance are all provider-agnostic on purpose. That is
|
|
not tidiness for its own sake: account resolution is the security-critical part,
|
|
and a second hand-rolled copy is where a subtle divergence becomes a takeover.
|
|
|
|
Two flows over one authorization-code grant, told apart by the signed `state`:
|
|
|
|
link — /oauth/{provider}/link/start a signed-in user attaches an identity
|
|
login — /oauth/{provider}/login/start an anonymous visitor authenticates
|
|
|
|
`state` is signed with the app's auth secret (stdlib hmac, no new dependency) and
|
|
carries the provider, the purpose, and — for a link — the Thermograph user id. All
|
|
three are inside the HMAC, so a state cannot be replayed, bound to a different
|
|
account, replayed as the other flow, or replayed against a different provider.
|
|
|
|
How a login resolves to an account, in order:
|
|
|
|
1. An ``oauth_account`` row for (provider, subject) — the durable key, no email
|
|
needed and immune to the user changing their address at the provider.
|
|
2. Otherwise the provider's email, but **only if it reports it verified**. That
|
|
flag is the sole evidence the person owns the address; matching an existing
|
|
Thermograph account on an unverified one would hand that account to whoever
|
|
typed the address into Discord or Google. Unverified is refused outright.
|
|
3. No account for that email: create one, flagged ``oauth_only`` because it has
|
|
no password its owner has ever seen.
|
|
|
|
**Legacy routes.** Discord shipped first, under /discord/*, and its callback URL is
|
|
registered in Discord's developer portal — so /discord/link/callback keeps working
|
|
forever, and the /discord/* aliases stay because the frontend deploys independently
|
|
of this service and an older one must keep working against a newer backend.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.parse
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy import delete, func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from accounts.db import get_async_session
|
|
from accounts.models import OAuthAccount, User
|
|
from accounts.users import (
|
|
SECRET,
|
|
attach_session,
|
|
current_active_user,
|
|
current_user_optional,
|
|
get_access_token_db,
|
|
get_user_manager,
|
|
)
|
|
from core import audit
|
|
|
|
router = APIRouter(tags=["oauth"])
|
|
|
|
BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").rstrip("/")
|
|
|
|
# How long a start->callback round trip may take before the signed state expires.
|
|
_STATE_TTL = 600
|
|
_TIMEOUT = httpx.Timeout(10.0)
|
|
|
|
|
|
# --- providers ----------------------------------------------------------------
|
|
def _parse_discord(p: dict) -> tuple[str, str, bool]:
|
|
return (str(p.get("id") or ""),
|
|
str(p.get("email") or "").strip().lower(),
|
|
p.get("verified") is True)
|
|
|
|
|
|
def _parse_google(p: dict) -> tuple[str, str, bool]:
|
|
# OpenID Connect userinfo. `email_verified` arrives as a real bool from the
|
|
# JSON endpoint, but Google has historically also serialised it as the string
|
|
# "true" in some responses, so accept both rather than silently reading a
|
|
# verified address as unverified and refusing every Google login.
|
|
verified = p.get("email_verified")
|
|
return (str(p.get("sub") or ""),
|
|
str(p.get("email") or "").strip().lower(),
|
|
verified is True or str(verified).lower() == "true")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Provider:
|
|
name: str
|
|
label: str
|
|
authorize_url: str
|
|
token_url: str
|
|
userinfo_url: str
|
|
# Linking needs only an identity; signing in also needs the address to resolve
|
|
# or create an account by, which is a separate scope the user is prompted for.
|
|
scope_link: str
|
|
scope_login: str
|
|
# Where this provider's callback lives. Discord's is grandfathered to the path
|
|
# already registered in its portal; anything new gets the canonical one.
|
|
callback_path: str
|
|
parse: Callable[[dict], tuple[str, str, bool]]
|
|
# Extra authorize-URL params. Google needs prompt=consent to re-issue consent,
|
|
# and Discord takes the same key, so this stays per-provider rather than shared.
|
|
extra_authorize: dict = field(default_factory=dict)
|
|
|
|
|
|
PROVIDERS: dict[str, Provider] = {
|
|
"discord": Provider(
|
|
name="discord",
|
|
label="Discord",
|
|
authorize_url="https://discord.com/api/oauth2/authorize",
|
|
token_url="https://discord.com/api/oauth2/token",
|
|
userinfo_url="https://discord.com/api/users/@me",
|
|
scope_link="identify",
|
|
scope_login="identify email",
|
|
callback_path="/api/v2/discord/link/callback",
|
|
parse=_parse_discord,
|
|
extra_authorize={"prompt": "consent"},
|
|
),
|
|
"google": Provider(
|
|
name="google",
|
|
label="Google",
|
|
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
|
|
token_url="https://oauth2.googleapis.com/token",
|
|
userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
|
|
# Google has no identity-only scope worth the name: openid alone yields a
|
|
# subject but no address, and we want the address recorded on the link too.
|
|
scope_link="openid email",
|
|
scope_login="openid email",
|
|
callback_path="/api/v2/oauth/google/callback",
|
|
parse=_parse_google,
|
|
# access_type=online: there is no offline work to do on the user's behalf,
|
|
# so asking for a refresh token would be collecting a credential we would
|
|
# never use. include_granted_scopes keeps incremental consent sane.
|
|
extra_authorize={"prompt": "consent", "access_type": "online",
|
|
"include_granted_scopes": "true"},
|
|
),
|
|
}
|
|
|
|
# Credentials, read once at import. A dict rather than per-provider constants so a
|
|
# test can enable or disable one provider without touching the others.
|
|
CREDENTIALS: dict[str, tuple[str, str]] = {
|
|
"discord": (os.environ.get("THERMOGRAPH_DISCORD_APP_ID", "").strip(),
|
|
os.environ.get("THERMOGRAPH_DISCORD_CLIENT_SECRET", "").strip()),
|
|
"google": (os.environ.get("THERMOGRAPH_GOOGLE_CLIENT_ID", "").strip(),
|
|
os.environ.get("THERMOGRAPH_GOOGLE_CLIENT_SECRET", "").strip()),
|
|
}
|
|
|
|
|
|
def enabled(provider: str) -> bool:
|
|
"""Whether this provider is configured. An unconfigured provider surfaces no UI
|
|
and every one of its routes bounces, so a half-set environment is inert rather
|
|
than broken."""
|
|
cid, secret = CREDENTIALS.get(provider, ("", ""))
|
|
return bool(cid and secret)
|
|
|
|
|
|
def _provider_or_404(name: str) -> Provider:
|
|
p = PROVIDERS.get(name)
|
|
if p is None:
|
|
raise HTTPException(status_code=404, detail="Unknown provider.")
|
|
return p
|
|
|
|
|
|
# --- signed state ------------------------------------------------------------
|
|
def _b64(raw: bytes) -> str:
|
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
|
|
|
|
def _unb64(s: str) -> bytes:
|
|
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
|
|
|
|
def _sign_state(uid: str | None, purpose: str, provider: str) -> str:
|
|
payload = _b64(json.dumps(
|
|
{"u": uid or "", "t": int(time.time()), "p": purpose, "pr": provider}
|
|
).encode())
|
|
mac = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
|
|
return f"{payload}.{mac}"
|
|
|
|
|
|
def _verify_state(state: str, purpose: str, provider: str) -> str | None:
|
|
"""The Thermograph user id this state was minted for, or None if it's missing,
|
|
tampered, expired, or was minted for a different purpose or provider.
|
|
|
|
A login state has no user id yet, so it verifies as ``""`` — falsy, but distinct
|
|
from the None that means reject. Callers must test against None, not
|
|
truthiness, or a valid login state reads as a failure.
|
|
"""
|
|
try:
|
|
payload, mac = state.split(".", 1)
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
expect = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32]
|
|
if not hmac.compare_digest(mac, expect):
|
|
return None
|
|
try:
|
|
data = json.loads(_unb64(payload))
|
|
except (ValueError, json.JSONDecodeError):
|
|
return None
|
|
if time.time() - float(data.get("t", 0)) > _STATE_TTL:
|
|
return None
|
|
if data.get("p") != purpose:
|
|
return None
|
|
# States minted before the provider field existed are Discord's; they age out
|
|
# in _STATE_TTL, so this fallback only spans a deploy.
|
|
if data.get("pr", "discord") != provider:
|
|
return None
|
|
return data.get("u") or ""
|
|
|
|
|
|
# --- redirects ---------------------------------------------------------------
|
|
def _redirect_uri(request: Request, provider: Provider) -> str:
|
|
"""The callback URL, matched to how the app is actually reached (scheme/host +
|
|
base path). Must equal the redirect registered in the provider's console."""
|
|
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
host = request.headers.get("host") or request.url.netloc
|
|
return f"{proto}://{host}{BASE}{provider.callback_path}"
|
|
|
|
|
|
def _authorize_redirect(request: Request, provider: Provider, scope: str,
|
|
state: str) -> RedirectResponse:
|
|
cid, _ = CREDENTIALS[provider.name]
|
|
params = {
|
|
"client_id": cid,
|
|
"redirect_uri": _redirect_uri(request, provider),
|
|
"response_type": "code",
|
|
"scope": scope,
|
|
"state": state,
|
|
**provider.extra_authorize,
|
|
}
|
|
return RedirectResponse(
|
|
url=f"{provider.authorize_url}?{urllib.parse.urlencode(params)}", status_code=303)
|
|
|
|
|
|
def _account_redirect(status: str, provider: str) -> RedirectResponse:
|
|
"""Back to the alerts page, which surfaces the outcome as a toast; 303 so the
|
|
browser issues a GET after the callback.
|
|
|
|
Emits `discord=` as well for Discord, because the frontend deploys
|
|
independently of this service and the one in production reads that parameter.
|
|
Drop it once no deployed frontend predates `oauth=`."""
|
|
params = {"oauth": status, "provider": provider}
|
|
if provider == "discord":
|
|
params["discord"] = status
|
|
return RedirectResponse(url=f"{BASE}/alerts?{urllib.parse.urlencode(params)}",
|
|
status_code=303)
|
|
|
|
|
|
# --- provider API -------------------------------------------------------------
|
|
async def _fetch_profile(request: Request, provider: Provider, code: str) -> dict | None:
|
|
"""Trade the authorization code for the provider's user object, or None if any
|
|
step fails. Callers treat None as a graceful error, never a crash."""
|
|
cid, secret = CREDENTIALS[provider.name]
|
|
try:
|
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
tok = await client.post(provider.token_url, data={
|
|
"client_id": cid,
|
|
"client_secret": secret,
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": _redirect_uri(request, provider),
|
|
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
|
|
if tok.status_code >= 300:
|
|
return None
|
|
access = tok.json().get("access_token")
|
|
if not access:
|
|
return None
|
|
me = await client.get(provider.userinfo_url,
|
|
headers={"Authorization": f"Bearer {access}"})
|
|
if me.status_code >= 300:
|
|
return None
|
|
profile = me.json()
|
|
except Exception: # noqa: BLE001 - any network/parse failure is a graceful error
|
|
return None
|
|
return profile if isinstance(profile, dict) else None
|
|
|
|
|
|
# --- persistence --------------------------------------------------------------
|
|
async def _account_for(session: AsyncSession, provider: str,
|
|
subject: str) -> OAuthAccount | None:
|
|
return (await session.execute(
|
|
select(OAuthAccount).where(OAuthAccount.provider == provider,
|
|
OAuthAccount.subject == subject)
|
|
)).scalar_one_or_none()
|
|
|
|
|
|
async def _link_row(session: AsyncSession, user_id, provider: str, subject: str,
|
|
email: str | None) -> None:
|
|
"""Bind (provider, subject) to a user, plus the Discord-only side effect.
|
|
|
|
discord_id is a *delivery* address, not an identity — notify.py DMs it — so the
|
|
Discord link has to write it as well as the oauth_account row. Turning DMs on
|
|
here mirrors the original behaviour: linking is an active opt-in, and the user
|
|
can mute it (POST /discord/dm) while staying linked.
|
|
"""
|
|
session.add(OAuthAccount(user_id=user_id, provider=provider, subject=subject,
|
|
email=email or None))
|
|
if provider == "discord":
|
|
await session.execute(
|
|
update(User).where(User.id == user_id)
|
|
.values(discord_id=subject, discord_dm=True))
|
|
await session.commit()
|
|
|
|
|
|
async def _create_user(user_manager, email: str) -> User:
|
|
"""Create an account for an identity that matched none of ours.
|
|
|
|
Deliberately goes around ``UserManager.create()``: there is no password to
|
|
validate, and its ``on_after_register`` mails a confirmation link for an address
|
|
the provider has already verified. The generated password exists only because
|
|
``hashed_password`` is NOT NULL — nobody ever sees it, which is what
|
|
``oauth_only`` records.
|
|
"""
|
|
password = user_manager.password_helper.generate()
|
|
return await user_manager.user_db.create({
|
|
"email": email,
|
|
"hashed_password": user_manager.password_helper.hash(password),
|
|
"is_active": True,
|
|
"is_superuser": False,
|
|
"is_verified": True,
|
|
"oauth_only": True,
|
|
})
|
|
|
|
|
|
# --- routes -------------------------------------------------------------------
|
|
@router.get("/oauth/config")
|
|
async def oauth_config():
|
|
"""Which providers this server can actually complete a flow with. The sign-in
|
|
modal and account menu render from this, so an unconfigured provider shows no
|
|
dead-end UI and configuring one later makes it appear on its own."""
|
|
return {"providers": [
|
|
{"name": p.name, "label": p.label, "enabled": enabled(p.name)}
|
|
for p in PROVIDERS.values()
|
|
]}
|
|
|
|
|
|
@router.get("/oauth/{provider}/link/start")
|
|
async def link_start(provider: str, request: Request, user=Depends(current_active_user)):
|
|
p = _provider_or_404(provider)
|
|
if not enabled(provider):
|
|
return _account_redirect("unavailable", provider)
|
|
return _authorize_redirect(request, p, p.scope_link,
|
|
_sign_state(str(user.id), "link", provider))
|
|
|
|
|
|
@router.get("/oauth/{provider}/login/start")
|
|
async def login_start(provider: str, request: Request,
|
|
user=Depends(current_user_optional)):
|
|
"""Sign in with a provider. Unauthenticated by design — this is how you get a
|
|
session, not something you do with one."""
|
|
p = _provider_or_404(provider)
|
|
if not enabled(provider):
|
|
return _account_redirect("unavailable", provider)
|
|
# Someone already signed in who lands here wants to connect, not to be switched
|
|
# into whichever account the external identity resolves to.
|
|
if user is not None:
|
|
return _authorize_redirect(request, p, p.scope_link,
|
|
_sign_state(str(user.id), "link", provider))
|
|
return _authorize_redirect(request, p, p.scope_login,
|
|
_sign_state(None, "login", provider))
|
|
|
|
|
|
async def _callback(provider: str, request: Request, user, session, user_manager,
|
|
access_token_db) -> RedirectResponse:
|
|
p = _provider_or_404(provider)
|
|
if not enabled(provider):
|
|
return _account_redirect("unavailable", provider)
|
|
# User declined on the provider's screen, or the state doesn't check out.
|
|
code = request.query_params.get("code")
|
|
state = request.query_params.get("state", "")
|
|
if not code:
|
|
return _account_redirect("cancelled", provider)
|
|
|
|
# Which flow minted this state? Purpose and provider are both inside the HMAC,
|
|
# so a state only verifies under the flow and provider it was signed for.
|
|
uid = _verify_state(state, "link", provider)
|
|
if uid is not None:
|
|
return await _finish_link(p, request, code, uid, user, session)
|
|
if _verify_state(state, "login", provider) is not None:
|
|
return await _finish_login(p, request, code, session, user_manager,
|
|
access_token_db)
|
|
return _account_redirect("error", provider)
|
|
|
|
|
|
@router.get("/oauth/{provider}/callback")
|
|
async def oauth_callback(
|
|
provider: str,
|
|
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),
|
|
):
|
|
return await _callback(provider, request, user, session, user_manager,
|
|
access_token_db)
|
|
|
|
|
|
async def _finish_link(p: Provider, request: Request, code: str, uid: str, user,
|
|
session: AsyncSession) -> RedirectResponse:
|
|
# Linking always acts on the person actually logged in, and only on the one the
|
|
# state was minted for.
|
|
if user is None or str(user.id) != uid:
|
|
return _account_redirect("error", p.name)
|
|
profile = await _fetch_profile(request, p, code)
|
|
if profile is None:
|
|
return _account_redirect("error", p.name)
|
|
subject, email, _ = p.parse(profile)
|
|
if not subject:
|
|
return _account_redirect("error", p.name)
|
|
# (provider, subject) is UNIQUE, so without this the insert would raise an
|
|
# IntegrityError and 500 instead of explaining itself.
|
|
owner = await _account_for(session, p.name, subject)
|
|
if owner is not None:
|
|
return _account_redirect("linked" if owner.user_id == user.id else "taken", p.name)
|
|
# And (user_id, provider) is UNIQUE: this account already has a different
|
|
# identity from this provider attached.
|
|
existing = (await session.execute(
|
|
select(OAuthAccount).where(OAuthAccount.user_id == user.id,
|
|
OAuthAccount.provider == p.name)
|
|
)).scalar_one_or_none()
|
|
if existing is not None:
|
|
return _account_redirect("already", p.name)
|
|
await _link_row(session, user.id, p.name, subject, email)
|
|
return _account_redirect("linked", p.name)
|
|
|
|
|
|
async def _finish_login(p: Provider, request: Request, code: str,
|
|
session: AsyncSession, user_manager,
|
|
access_token_db) -> RedirectResponse:
|
|
"""Resolve the external identity to an account and sign in as it.
|
|
|
|
Deliberately ignores any session already present. A login state carries no user
|
|
id — it can't, there is no user yet — so it is replayable against whoever
|
|
happens to be signed in. Treating it as a link would therefore be a
|
|
forced-linking takeover: an attacker who gets a victim to open this callback
|
|
with a code from the *attacker's* provider account would attach their identity
|
|
to the victim's account, then sign in as them. Only link/start, whose state is
|
|
bound to a specific user id, may link. The worst a replayed login state can do
|
|
is sign someone into the attacker's own account.
|
|
"""
|
|
profile = await _fetch_profile(request, p, code)
|
|
if profile is None:
|
|
return _account_redirect("error", p.name)
|
|
subject, email, verified = p.parse(profile)
|
|
if not subject:
|
|
return _account_redirect("error", p.name)
|
|
|
|
created = False
|
|
acct = await _account_for(session, p.name, subject)
|
|
if acct is not None:
|
|
user = await user_manager.user_db.get(acct.user_id)
|
|
if user is None:
|
|
return _account_redirect("error", p.name)
|
|
else:
|
|
# Nothing carries this identity, so fall back to the email — the only other
|
|
# identity the provider gives us, and only when it vouches for it.
|
|
if not email:
|
|
return _account_redirect("noemail", p.name)
|
|
if not verified:
|
|
return _account_redirect("unverified", p.name)
|
|
user = await user_manager.user_db.get_by_email(email)
|
|
if user is None:
|
|
user = await _create_user(user_manager, email)
|
|
await _link_row(session, user.id, p.name, subject, email)
|
|
audit.log_activity("auth.register", {"user_id": str(user.id), "via": p.name})
|
|
created = True
|
|
elif not user.is_active:
|
|
# Checked before the link, not just before the session: a deactivated
|
|
# account must not quietly acquire an identity on the way out.
|
|
return _account_redirect("inactive", p.name)
|
|
else:
|
|
# That address belongs to an account already tied to a different
|
|
# identity from this provider; connecting would silently move it.
|
|
existing = (await session.execute(
|
|
select(OAuthAccount).where(OAuthAccount.user_id == user.id,
|
|
OAuthAccount.provider == p.name)
|
|
)).scalar_one_or_none()
|
|
if existing is not None:
|
|
return _account_redirect("mismatch", p.name)
|
|
await _link_row(session, user.id, p.name, subject, email)
|
|
if not user.is_active:
|
|
return _account_redirect("inactive", p.name)
|
|
response = _account_redirect("created" if created else "signedin", p.name)
|
|
return await attach_session(response, user, user_manager, access_token_db, request)
|
|
|
|
|
|
async def _unlink(provider: str, user, session: AsyncSession) -> None:
|
|
p = _provider_or_404(provider)
|
|
linked = (await session.execute(
|
|
select(func.count()).select_from(OAuthAccount)
|
|
.where(OAuthAccount.user_id == user.id)
|
|
)).scalar_one()
|
|
mine = await session.execute(
|
|
select(OAuthAccount).where(OAuthAccount.user_id == user.id,
|
|
OAuthAccount.provider == p.name))
|
|
if mine.scalar_one_or_none() is None:
|
|
return # already unlinked; idempotent
|
|
if user.oauth_only and linked <= 1:
|
|
# No password anyone has ever seen, and this is the last way in. No
|
|
# reset-password route is mounted to recover from it either.
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"This account was created with {p.label} and has no password, "
|
|
"so unlinking would leave no way to sign in.",
|
|
)
|
|
await session.execute(
|
|
delete(OAuthAccount).where(OAuthAccount.user_id == user.id,
|
|
OAuthAccount.provider == p.name))
|
|
if p.name == "discord":
|
|
await session.execute(
|
|
update(User).where(User.id == user.id)
|
|
.values(discord_id=None, discord_dm=False))
|
|
await session.commit()
|
|
|
|
|
|
@router.post("/oauth/{provider}/unlink", status_code=204)
|
|
async def unlink(
|
|
provider: str,
|
|
user=Depends(current_active_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
):
|
|
await _unlink(provider, user, session)
|