Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions host/migrations/versions/3bf3f9db7f7f_users_oauth_account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""users oauth_account

Revision ID: 3bf3f9db7f7f
Revises: 77162e7b184b
Create Date: 2026-05-06 18:42:33.343257
"""

from collections.abc import Sequence

import fastapi_users_db_sqlalchemy.generics
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "3bf3f9db7f7f"
down_revision: str | None = "77162e7b184b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users_oauth_account",
sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column("user_id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column("oauth_name", sa.String(length=100), nullable=False),
sa.Column("access_token", sa.String(length=1024), nullable=False),
sa.Column("expires_at", sa.Integer(), nullable=True),
sa.Column("refresh_token", sa.String(length=1024), nullable=True),
sa.Column("account_id", sa.String(length=320), nullable=False),
sa.Column("account_email", sa.String(length=320), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["users_user.id"],
name=op.f("fk_users_oauth_account_user_id_users_user"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users_oauth_account")),
)
op.create_index(
op.f("ix_users_oauth_account_account_id"),
"users_oauth_account",
["account_id"],
unique=False,
)
op.create_index(
op.f("ix_users_oauth_account_oauth_name"),
"users_oauth_account",
["oauth_name"],
unique=False,
)
op.create_index(
op.f("ix_users_oauth_account_user_id"), "users_oauth_account", ["user_id"], unique=False
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_users_oauth_account_user_id"), table_name="users_oauth_account")
op.drop_index(op.f("ix_users_oauth_account_oauth_name"), table_name="users_oauth_account")
op.drop_index(op.f("ix_users_oauth_account_account_id"), table_name="users_oauth_account")
op.drop_table("users_oauth_account")
# ### end Alembic commands ###
2 changes: 1 addition & 1 deletion modules/users/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ dependencies = [
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
# fields after construction (see reconfigure_cookie_transport in backend.py).
# Bumping the major version requires re-checking those field names.
"fastapi-users[sqlalchemy]>=15,<16",
"fastapi-users[sqlalchemy,oauth]>=15,<16",
"aiosmtplib>=3.0",
"cachetools>=5.3",
"typer>=0.12",
Expand Down
189 changes: 189 additions & 0 deletions modules/users/tests/test_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Unit + integration tests for the OAuth/OIDC plumbing.

Provider client construction and the /authorize+/callback ASGI flow are not
covered here because both depend on real httpx-oauth clients that hit the
network (token exchange, profile fetch). Those are best validated in a manual
QA pass against a dev IdP. What this file *does* cover:

- ``enabled_provider_names`` correctly reflects settings.
- ``build_clients`` instantiates the Google + GitHub clients when configured.
- ``OAuthAccount`` persists and FK-cascades on user delete.
- ``UserManager.oauth_callback`` (the find-or-create core fastapi-users helper
the route delegates to) creates a fresh user + linked OAuthAccount, and
associates by email when the user already exists.
"""

from __future__ import annotations

import uuid

import pytest
from fastapi_users.password import PasswordHelper
from sqlalchemy import select
from users.models import OAuthAccount, User
from users.oauth import build_clients, enabled_provider_names
from users.settings import UsersSettings

_pw = PasswordHelper()


# ---------------------------------------------------------------------------
# Settings → provider list
# ---------------------------------------------------------------------------


def test_enabled_provider_names_empty_by_default():
assert enabled_provider_names(UsersSettings()) == []


def test_enabled_provider_names_lists_configured_providers():
s = UsersSettings(
oauth_google_client_id="g-id",
oauth_google_client_secret="g-secret",
oauth_github_client_id="gh-id",
oauth_github_client_secret="gh-secret",
)
names = [p["name"] for p in enabled_provider_names(s)]
assert names == ["google", "github"]


def test_enabled_provider_names_skips_provider_missing_secret():
s = UsersSettings(oauth_google_client_id="g-id") # no secret
assert enabled_provider_names(s) == []


def test_enabled_provider_names_oidc_requires_discovery_url():
s = UsersSettings(
oauth_oidc_client_id="x",
oauth_oidc_client_secret="y",
# discovery_url unset → not registered
)
assert enabled_provider_names(s) == []


# ---------------------------------------------------------------------------
# build_clients (no-network providers only)
# ---------------------------------------------------------------------------


def test_build_clients_google_and_github():
s = UsersSettings(
oauth_google_client_id="g-id",
oauth_google_client_secret="g-secret",
oauth_github_client_id="gh-id",
oauth_github_client_secret="gh-secret",
)
providers = build_clients(s)
assert [p.name for p in providers] == ["google", "github"]
# Sanity-check that the underlying httpx-oauth client carries our id.
assert providers[0].client.client_id == "g-id"
assert providers[1].client.client_id == "gh-id"


# ---------------------------------------------------------------------------
# OAuthAccount persistence + cascade
# ---------------------------------------------------------------------------


@pytest.mark.anyio
async def test_oauth_account_round_trip_and_cascade(users_db):
user = User(
id=uuid.uuid4(),
email="oauth-rt@example.com",
hashed_password=_pw.hash("SecurePass1!"),
is_active=True,
is_verified=True,
)
users_db.add(user)
await users_db.commit()

account = OAuthAccount(
user_id=user.id,
oauth_name="google",
access_token="tok",
account_id="google-123",
account_email=user.email,
)
users_db.add(account)
await users_db.commit()

found = (
await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
).scalar_one()
assert found.user_id == user.id

# FK cascade: deleting the user removes the linked account.
await users_db.delete(user)
await users_db.commit()
remaining = (
await users_db.execute(select(OAuthAccount).where(OAuthAccount.account_id == "google-123"))
).scalar_one_or_none()
assert remaining is None


# ---------------------------------------------------------------------------
# UserManager.oauth_callback — the find-or-create flow the route delegates to
# ---------------------------------------------------------------------------


async def _build_user_manager(app):
"""Construct a UserManager bound to the test app's DB session."""
from users.db_adapter import UserDatabaseWithRoles
from users.manager import UserManager
from users.models import OAuthAccount, User

session = app.state.sm.db.session_factory()
s = await session.__aenter__()
user_db = UserDatabaseWithRoles(s, User, OAuthAccount)
manager = UserManager(user_db, app.state.users.mailer, app.state.users.settings)
return manager, session, s


@pytest.mark.anyio
async def test_oauth_callback_creates_new_user_and_account(users_app):
manager, session, _ = await _build_user_manager(users_app)
try:
user = await manager.oauth_callback(
"google",
access_token="tok",
account_id="google-new-1",
account_email="newuser@example.com",
associate_by_email=True,
is_verified_by_default=True,
)
assert user.email == "newuser@example.com"
assert user.is_verified is True
assert len(user.oauth_accounts) == 1
assert user.oauth_accounts[0].oauth_name == "google"
assert user.oauth_accounts[0].account_id == "google-new-1"
finally:
await session.__aexit__(None, None, None)


@pytest.mark.anyio
async def test_oauth_callback_links_to_existing_email(users_app, users_db):
existing = User(
id=uuid.uuid4(),
email="existing@example.com",
hashed_password=_pw.hash("SecurePass1!"),
is_active=True,
is_verified=True,
)
users_db.add(existing)
await users_db.commit()

manager, session, _ = await _build_user_manager(users_app)
try:
linked = await manager.oauth_callback(
"github",
access_token="tok",
account_id="gh-42",
account_email="existing@example.com",
associate_by_email=True,
is_verified_by_default=True,
)
assert linked.id == existing.id
names = [a.oauth_name for a in linked.oauth_accounts]
assert names == ["github"]
finally:
await session.__aexit__(None, None, None)
7 changes: 5 additions & 2 deletions modules/users/users/db_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from users.models import User, UserAccessToken
from users.models import OAuthAccount, User, UserAccessToken


class UserDatabaseWithRoles(SQLAlchemyUserDatabase):
Expand All @@ -39,7 +39,10 @@ async def get_by_email(self, email):
async def get_user_db(
session: AsyncSession = Depends(get_db),
) -> AsyncGenerator[UserDatabaseWithRoles, None]:
yield UserDatabaseWithRoles(session, User)
# OAuthAccount enables fastapi-users' OAuth router (get_by_oauth_account /
# add_oauth_account / update_oauth_account). Password-only flows are
# unaffected — those code paths never touch oauth_account_table.
yield UserDatabaseWithRoles(session, User, OAuthAccount)


async def get_access_token_db(
Expand Down
8 changes: 7 additions & 1 deletion modules/users/users/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
get_user_manager,
)
from users.endpoints.api_admin import admin_router
from users.endpoints.api_oauth import register_oauth_routes
from users.manager import UserManager
from users.rate_limit import LoginRateLimiter, ThroughputLimiter
from users.settings import UsersSettings

logger = logging.getLogger(__name__)
router = APIRouter()
Expand Down Expand Up @@ -125,7 +127,7 @@ async def login(
router.include_router(auth_inner, prefix="/auth-inner")


def register_auth_routes(api_router: APIRouter) -> None:
def register_auth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
"""Mount all auth routes.

The stock fastapi-users routers (reset/verify/register) ship POST endpoints
Expand All @@ -137,6 +139,9 @@ def register_auth_routes(api_router: APIRouter) -> None:

The register router is always mounted; ``require_signup_enabled`` gates
it at request time so ``allow_signup`` is hot-reloadable.

OAuth providers configured in ``settings`` are mounted under
``/auth/<provider>/{login,callback}`` — see :mod:`users.endpoints.api_oauth`.
"""
api_router.include_router(router)
api_router.include_router(
Expand All @@ -160,6 +165,7 @@ def register_auth_routes(api_router: APIRouter) -> None:
Depends(enforce_auth_throughput_limit),
],
)
register_oauth_routes(api_router, settings)


# ── Accept-invite (verify + set password + login, one shot) ─────────────────
Expand Down
Loading
Loading