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.
104 lines
4.7 KiB
Python
104 lines
4.7 KiB
Python
"""oauth_account table; user.discord_only -> user.oauth_only
|
|
|
|
Generalises "sign in with Discord" to any provider (see accounts/oauth.py). Three
|
|
steps, each conditional so the revision is safe on a database created before or
|
|
after the ORM gained these definitions — revision 0001 builds the schema with
|
|
``Base.metadata.create_all`` off *live* model metadata, so a freshly created
|
|
database already has both the table and the renamed column, and an unconditional
|
|
DDL would fail there with duplicate-object errors.
|
|
|
|
The backfill is the load-bearing step. Discord identities used to live in
|
|
``user.discord_id``, and account resolution now keys on ``oauth_account``; without
|
|
copying the existing rows across, every already-linked user would silently stop
|
|
being recognised at login and would get a *second* account created on their next
|
|
sign-in. ``user.discord_id`` is deliberately left in place — it is the DM delivery
|
|
address notify.py reads, not merely an identity.
|
|
|
|
Revision ID: 0005_oauth_accounts
|
|
Revises: 0004_bookmarks
|
|
Create Date: 2026-07-26
|
|
"""
|
|
import time
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
# The same UUID type the ORM uses for user.id — a plain sa.Uuid renders differently
|
|
# on SQLite and would not match the column create_all produces.
|
|
from fastapi_users_db_sqlalchemy.generics import GUID
|
|
|
|
revision = "0005_oauth_accounts"
|
|
down_revision = "0004_bookmarks"
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def _columns(table: str) -> set[str]:
|
|
return {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
|
|
|
|
|
|
def _tables() -> set[str]:
|
|
return set(sa.inspect(op.get_bind()).get_table_names())
|
|
|
|
|
|
def upgrade() -> None:
|
|
cols = _columns("user")
|
|
|
|
# 1. Rename the flag. It no longer means "Discord created this account", it
|
|
# means "no password its owner has ever seen, whichever provider made it".
|
|
if "oauth_only" in cols:
|
|
# A database built by 0001 already has oauth_only (create_all reads *current*
|
|
# metadata), and 0003 then re-added an empty discord_only beside it, because
|
|
# 0003 tests for the name it knew. Drop that vestige, or a fresh database
|
|
# carries a column an upgraded one does not — schema drift that would only
|
|
# surface much later. Safe: on this path the column was created empty
|
|
# moments ago and nothing has ever read it.
|
|
if "discord_only" in cols:
|
|
op.drop_column("user", "discord_only")
|
|
elif "discord_only" in cols:
|
|
# The real upgrade path: a deployed database carrying live values. Renamed,
|
|
# never dropped, so nothing is lost.
|
|
op.alter_column("user", "discord_only", new_column_name="oauth_only")
|
|
else:
|
|
# Database predates even discord_only (never deployed with it).
|
|
op.add_column("user", sa.Column("oauth_only", sa.Boolean(), nullable=False,
|
|
server_default=sa.false()))
|
|
|
|
# 2. The identity table.
|
|
if "oauth_account" not in _tables():
|
|
op.create_table(
|
|
"oauth_account",
|
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column("user_id", GUID(), sa.ForeignKey("user.id", ondelete="CASCADE"),
|
|
nullable=False),
|
|
sa.Column("provider", sa.String(length=32), nullable=False),
|
|
sa.Column("subject", sa.String(length=64), nullable=False),
|
|
sa.Column("email", sa.String(length=320), nullable=True),
|
|
sa.Column("created_at", sa.Float(), nullable=False),
|
|
sa.UniqueConstraint("provider", "subject", name="uq_oauth_provider_subject"),
|
|
sa.UniqueConstraint("user_id", "provider", name="uq_oauth_user_provider"),
|
|
)
|
|
op.create_index("idx_oauth_user", "oauth_account", ["user_id"])
|
|
|
|
# 3. Backfill every existing Discord link. Guarded by NOT EXISTS so re-running
|
|
# (or running after create_all already produced the table) cannot violate
|
|
# either unique constraint.
|
|
op.execute(sa.text("""
|
|
INSERT INTO oauth_account (user_id, provider, subject, email, created_at)
|
|
SELECT u.id, 'discord', u.discord_id, u.email, :now
|
|
FROM "user" u
|
|
WHERE u.discord_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM oauth_account o
|
|
WHERE o.provider = 'discord'
|
|
AND (o.subject = u.discord_id OR o.user_id = u.id)
|
|
)
|
|
""").bindparams(now=time.time()))
|
|
|
|
|
|
def downgrade() -> None:
|
|
if "oauth_account" in _tables():
|
|
op.drop_index("idx_oauth_user", table_name="oauth_account")
|
|
op.drop_table("oauth_account")
|
|
cols = _columns("user")
|
|
if "oauth_only" in cols and "discord_only" not in cols:
|
|
op.alter_column("user", "oauth_only", new_column_name="discord_only")
|