diff --git a/host/migrations/versions/3bf3f9db7f7f_users_oauth_account.py b/host/migrations/versions/3bf3f9db7f7f_users_oauth_account.py
new file mode 100644
index 00000000..3250f3b9
--- /dev/null
+++ b/host/migrations/versions/3bf3f9db7f7f_users_oauth_account.py
@@ -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 ###
diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml
index ca4c38f3..fd58c464 100644
--- a/modules/users/pyproject.toml
+++ b/modules/users/pyproject.toml
@@ -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",
diff --git a/modules/users/tests/test_oauth.py b/modules/users/tests/test_oauth.py
new file mode 100644
index 00000000..ba97bc0f
--- /dev/null
+++ b/modules/users/tests/test_oauth.py
@@ -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)
diff --git a/modules/users/users/db_adapter.py b/modules/users/users/db_adapter.py
index a27ad2ee..3e395c97 100644
--- a/modules/users/users/db_adapter.py
+++ b/modules/users/users/db_adapter.py
@@ -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):
@@ -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(
diff --git a/modules/users/users/endpoints/api.py b/modules/users/users/endpoints/api.py
index 73b17b06..352afa0d 100644
--- a/modules/users/users/endpoints/api.py
+++ b/modules/users/users/endpoints/api.py
@@ -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()
@@ -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
@@ -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//{login,callback}`` — see :mod:`users.endpoints.api_oauth`.
"""
api_router.include_router(router)
api_router.include_router(
@@ -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) ─────────────────
diff --git a/modules/users/users/endpoints/api_oauth.py b/modules/users/users/endpoints/api_oauth.py
new file mode 100644
index 00000000..0d0c80fb
--- /dev/null
+++ b/modules/users/users/endpoints/api_oauth.py
@@ -0,0 +1,131 @@
+"""OAuth/OIDC login routes — one pair (``/login``, ``/callback``) per provider.
+
+Why a custom handler rather than ``fastapi_users.get_oauth_router``: the stock
+router's ``/callback`` returns a 204 No Content with the auth cookie set. That
+works for SPA flows that redirect on a successful AJAX response, but Inertia
+expects the user's browser to land on a real page. Here ``/callback`` returns
+a 303 redirect to ``settings.login_redirect_url`` instead, with the same
+cookie attached.
+
+Find-or-create + email-association logic still goes through
+``UserManager.oauth_callback`` — we don't reimplement it, only the transport
+around it. State CSRF uses Starlette's signed session cookie (already mounted
+by the framework) instead of fastapi-users' separate JWT-state cookie.
+"""
+
+from __future__ import annotations
+
+import logging
+import secrets
+from typing import TYPE_CHECKING
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi_users import exceptions as fu_exceptions
+from starlette.responses import RedirectResponse
+
+from users.deps import auth_backend, get_user_manager
+from users.oauth import OAuthProvider, build_clients
+
+if TYPE_CHECKING:
+ from users.manager import UserManager
+ from users.settings import UsersSettings
+
+logger = logging.getLogger(__name__)
+
+_SESSION_STATE_KEY_FMT = "oauth_state:{provider}"
+
+
+def _build_provider_router(provider: OAuthProvider, login_redirect_url: str) -> APIRouter:
+ """Mount /login + /callback for one provider."""
+ router = APIRouter()
+ state_key = _SESSION_STATE_KEY_FMT.format(provider=provider.name)
+
+ @router.get("/login")
+ async def begin(request: Request) -> RedirectResponse:
+ """Generate a state nonce, stash it in the session, redirect to the IdP."""
+ state = secrets.token_urlsafe(32)
+ request.session[state_key] = state
+ callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
+ authorization_url = await provider.client.get_authorization_url(callback_url, state)
+ return RedirectResponse(authorization_url, status_code=302)
+
+ @router.get("/callback", name=f"oauth_{provider.name}_callback")
+ async def callback(
+ request: Request,
+ code: str | None = None,
+ state: str | None = None,
+ user_manager: UserManager = Depends(get_user_manager),
+ strategy=Depends(auth_backend.get_strategy),
+ ) -> RedirectResponse:
+ """Verify state, exchange code, find-or-create user, set cookie, redirect."""
+ expected_state = request.session.pop(state_key, None)
+ if not state or not expected_state or not secrets.compare_digest(state, expected_state):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_INVALID_STATE"
+ )
+ if not code:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_MISSING_CODE"
+ )
+
+ callback_url = str(request.url_for(f"oauth_{provider.name}_callback"))
+ token = await provider.client.get_access_token(code, callback_url)
+ account_id, account_email = await provider.client.get_id_email(token["access_token"])
+ if account_email is None:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAUTH_NO_EMAIL")
+
+ try:
+ user = await user_manager.oauth_callback(
+ provider.name,
+ token["access_token"],
+ account_id,
+ account_email,
+ token.get("expires_at"),
+ token.get("refresh_token"),
+ request,
+ associate_by_email=True,
+ is_verified_by_default=True,
+ )
+ except fu_exceptions.UserAlreadyExists:
+ # Email exists but associate_by_email=False would forbid linking.
+ # We always pass True above, so this branch only fires if the
+ # provider returns ambiguous data.
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="OAUTH_USER_ALREADY_EXISTS",
+ ) from None
+
+ if not user.is_active:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST, detail="LOGIN_BAD_CREDENTIALS"
+ )
+
+ # Set the auth cookie via the existing backend, then bridge the
+ # session in on_after_login (sets session["user_id"] for AuthMiddleware).
+ login_response = await auth_backend.login(strategy, user)
+ await user_manager.on_after_login(user, request, login_response)
+
+ redirect = RedirectResponse(login_redirect_url, status_code=303)
+ for key, value in login_response.headers.items():
+ if key.lower() == "set-cookie":
+ redirect.raw_headers.append((b"set-cookie", value.encode("latin-1")))
+ return redirect
+
+ return router
+
+
+def register_oauth_routes(api_router: APIRouter, settings: UsersSettings) -> None:
+ """Mount /auth//{login,callback} for every configured provider."""
+ providers = build_clients(settings)
+ for provider in providers:
+ api_router.include_router(
+ _build_provider_router(provider, settings.login_redirect_url),
+ prefix=f"/auth/{provider.name}",
+ tags=["users-auth"],
+ )
+ if providers:
+ logger.info(
+ "Registered %d OAuth provider(s): %s",
+ len(providers),
+ ", ".join(p.name for p in providers),
+ )
diff --git a/modules/users/users/endpoints/views.py b/modules/users/users/endpoints/views.py
index a64bf733..500a252c 100644
--- a/modules/users/users/endpoints/views.py
+++ b/modules/users/users/endpoints/views.py
@@ -14,6 +14,7 @@
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
from users.deps import get_user_service
from users.exceptions import UserNotFoundError
+from users.oauth import enabled_provider_names
from users.roles_cache import get_roles_cache
from users.service import UserService
@@ -72,6 +73,7 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
"allow_signup": users_settings.allow_signup,
"dev_accounts": dev_accounts,
"login_redirect_url": users_settings.login_redirect_url,
+ "oauth_providers": enabled_provider_names(users_settings),
},
)
diff --git a/modules/users/users/manager.py b/modules/users/users/manager.py
index 543f62df..e09737c6 100644
--- a/modules/users/users/manager.py
+++ b/modules/users/users/manager.py
@@ -11,6 +11,7 @@
from fastapi_users import BaseUserManager, UUIDIDMixin, exceptions
from fastapi_users.jwt import generate_jwt
+from users.constants import SESSION_USER_ID_KEY
from users.contracts.events import UserRegistered
from users.db_adapter import UserDatabaseWithRoles, get_user_db
from users.mailer import Mailer
@@ -86,6 +87,13 @@ async def on_after_login(
) -> None:
user.last_login_at = datetime.now(UTC)
await self.user_db.update(user, {"last_login_at": user.last_login_at})
+ # Bridge to AuthMiddleware: it reads session["user_id"] (not the
+ # fastapi-users cookie) to identify the request principal. Setting it
+ # here covers OAuth callbacks too, where there's no wrapper to do it
+ # explicitly. Password / accept-invite flows already set this in their
+ # wrappers — re-assigning the same value here is a harmless no-op.
+ if request is not None:
+ request.session[SESSION_USER_ID_KEY] = str(user.id)
# ── Token helpers (no email side-effect) ─────────────────
diff --git a/modules/users/users/models/__init__.py b/modules/users/users/models/__init__.py
index a6f69263..bbfdd733 100644
--- a/modules/users/users/models/__init__.py
+++ b/modules/users/users/models/__init__.py
@@ -9,12 +9,14 @@
from users.models._base import Base
from users.models.access_token import UserAccessToken
+from users.models.oauth_account import OAuthAccount
from users.models.role import Role
from users.models.user import User
from users.models.user_role import UserRole
__all__ = [
"Base",
+ "OAuthAccount",
"Role",
"SQLAlchemyAccessTokenDatabase",
"SQLAlchemyUserDatabase",
diff --git a/modules/users/users/models/oauth_account.py b/modules/users/users/models/oauth_account.py
new file mode 100644
index 00000000..26f26b03
--- /dev/null
+++ b/modules/users/users/models/oauth_account.py
@@ -0,0 +1,41 @@
+"""OAuth account table — links a (provider, account_id) pair to a User row.
+
+Column surface mirrors fastapi-users' ``SQLAlchemyBaseOAuthAccountTableUUID``
+so ``SQLAlchemyUserDatabase`` binds to it without inheriting from the upstream
+base class (whose ``Mapped[...]`` columns are incompatible with SQLModel's
+metaclass — same constraint as ``User`` / ``UserAccessToken``).
+"""
+
+# NOTE: intentionally no ``from __future__ import annotations`` — SQLModel
+# Relationship resolution requires runtime annotations.
+
+import uuid
+
+from fastapi_users_db_sqlalchemy.generics import GUID
+from sqlmodel import Field
+
+from users.models._base import Base
+
+
+class OAuthAccount(Base, table=True): # ty: ignore[unsupported-base]
+ """One row per (provider, account_id) link to a local User."""
+
+ __tablename__ = "users_oauth_account"
+
+ id: uuid.UUID = Field(
+ default_factory=uuid.uuid4,
+ sa_type=GUID,
+ primary_key=True,
+ )
+ user_id: uuid.UUID = Field(
+ sa_type=GUID,
+ foreign_key="users_user.id",
+ ondelete="CASCADE",
+ index=True,
+ )
+ oauth_name: str = Field(max_length=100, index=True)
+ access_token: str = Field(max_length=1024)
+ expires_at: int | None = Field(default=None)
+ refresh_token: str | None = Field(default=None, max_length=1024)
+ account_id: str = Field(max_length=320, index=True)
+ account_email: str = Field(max_length=320)
diff --git a/modules/users/users/models/user.py b/modules/users/users/models/user.py
index 2106a245..e373423f 100644
--- a/modules/users/users/models/user.py
+++ b/modules/users/users/models/user.py
@@ -25,6 +25,7 @@
if TYPE_CHECKING:
# Resolved at runtime by SQLModel via the string forward ref;
# this import only feeds the type checker.
+ from users.models.oauth_account import OAuthAccount
from users.models.role import Role
@@ -62,6 +63,17 @@ class User(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
sa_relationship_kwargs={"lazy": "noload"},
)
+ # fastapi-users' SQLAlchemyUserDatabase.add_oauth_account does
+ # ``user.oauth_accounts.append(...)``, so this attribute must exist.
+ # ``selectin`` so the OAuth router can read the list without an
+ # implicit async lazy-load.
+ oauth_accounts: list["OAuthAccount"] = Relationship(
+ sa_relationship_kwargs={
+ "lazy": "selectin",
+ "cascade": "all, delete-orphan",
+ },
+ )
+
# Functional index so the ``lower(email)`` predicate used by
# ``UserDatabaseWithRoles.get_by_email`` can be served from an index.
__table_args__ = (Index("ix_users_user_email_lower", text("lower(email)")),)
diff --git a/modules/users/users/module.py b/modules/users/users/module.py
index 171628c5..0684da2c 100644
--- a/modules/users/users/module.py
+++ b/modules/users/users/module.py
@@ -112,11 +112,12 @@ def register_menu_items(self, registry: MenuRegistry) -> None:
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
from users.endpoints.api import register_auth_routes
from users.endpoints.views import router as views
+ from users.settings import UsersSettings
- # The register router is always mounted; its `allow_signup` gate lives
- # on a per-request dependency, so toggling the setting at runtime takes
- # effect without needing to remount.
- register_auth_routes(api_router)
+ # Construct settings here (re-reads env_str-bound fields like OAuth
+ # client ids/secrets). Validators have already passed by this point —
+ # ``register_settings`` ran first and would have raised on placeholders.
+ register_auth_routes(api_router, UsersSettings())
view_router.include_router(views)
def register_middleware(self, app: FastAPI) -> None:
diff --git a/modules/users/users/oauth.py b/modules/users/users/oauth.py
new file mode 100644
index 00000000..f371314f
--- /dev/null
+++ b/modules/users/users/oauth.py
@@ -0,0 +1,120 @@
+"""OAuth/OIDC provider client factory.
+
+Constructs the ``httpx_oauth`` clients for every provider that has both
+``client_id`` and ``client_secret`` set in :class:`UsersSettings`. A provider
+with no credentials is silently skipped — that's the "feature flag" knob.
+
+Lives in its own module so :func:`UsersModule.register_routes` can import it
+without dragging the heavy ``httpx_oauth`` packages into the cold-start path
+when no provider is configured.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, NamedTuple
+
+if TYPE_CHECKING:
+ from httpx_oauth.oauth2 import BaseOAuth2
+
+ from users.settings import UsersSettings
+
+logger = logging.getLogger(__name__)
+
+
+class OAuthProvider(NamedTuple):
+ """One configured provider — name is the URL segment (``/auth/``)."""
+
+ name: str
+ display_name: str
+ client: BaseOAuth2
+
+
+def enabled_provider_names(settings: UsersSettings) -> list[dict[str, str]]:
+ """Return ``[{"name": ..., "display_name": ...}]`` for configured providers.
+
+ Cheap settings-only check used by the login page to render social-login
+ buttons. Does not construct clients or hit the network — that would be
+ wasteful per page render and would fail-open if discovery is briefly
+ unreachable.
+ """
+ out: list[dict[str, str]] = []
+ if settings.oauth_google_client_id and settings.oauth_google_client_secret:
+ out.append({"name": "google", "display_name": "Google"})
+ if settings.oauth_github_client_id and settings.oauth_github_client_secret:
+ out.append({"name": "github", "display_name": "GitHub"})
+ if (
+ settings.oauth_oidc_client_id
+ and settings.oauth_oidc_client_secret
+ and settings.oauth_oidc_discovery_url
+ ):
+ out.append({"name": "oidc", "display_name": settings.oauth_oidc_display_name or "OIDC"})
+ return out
+
+
+def build_clients(settings: UsersSettings) -> list[OAuthProvider]:
+ """Return one entry per provider that has both id and secret configured.
+
+ The generic OIDC provider also requires a discovery URL. If discovery
+ fetch fails at construction time, the provider is logged and skipped
+ rather than raising — a misconfigured IdP must not break boot.
+ """
+ out: list[OAuthProvider] = []
+
+ if settings.oauth_google_client_id and settings.oauth_google_client_secret:
+ from httpx_oauth.clients.google import GoogleOAuth2
+
+ out.append(
+ OAuthProvider(
+ "google",
+ "Google",
+ GoogleOAuth2(
+ settings.oauth_google_client_id,
+ settings.oauth_google_client_secret,
+ ),
+ )
+ )
+
+ if settings.oauth_github_client_id and settings.oauth_github_client_secret:
+ from httpx_oauth.clients.github import GitHubOAuth2
+
+ out.append(
+ OAuthProvider(
+ "github",
+ "GitHub",
+ GitHubOAuth2(
+ settings.oauth_github_client_id,
+ settings.oauth_github_client_secret,
+ ),
+ )
+ )
+
+ if (
+ settings.oauth_oidc_client_id
+ and settings.oauth_oidc_client_secret
+ and settings.oauth_oidc_discovery_url
+ ):
+ from httpx_oauth.clients.openid import OpenID, OpenIDConfigurationError
+
+ try:
+ client = OpenID(
+ settings.oauth_oidc_client_id,
+ settings.oauth_oidc_client_secret,
+ settings.oauth_oidc_discovery_url,
+ name="oidc",
+ )
+ except OpenIDConfigurationError:
+ logger.exception(
+ "OIDC discovery failed for %s — provider disabled",
+ settings.oauth_oidc_discovery_url,
+ )
+ else:
+ out.append(
+ OAuthProvider(
+ "oidc",
+ settings.oauth_oidc_display_name or "OIDC",
+ client,
+ )
+ )
+
+ return out
diff --git a/modules/users/users/pages/Login.tsx b/modules/users/users/pages/Login.tsx
index d50090c8..8b88978d 100644
--- a/modules/users/users/pages/Login.tsx
+++ b/modules/users/users/pages/Login.tsx
@@ -12,15 +12,22 @@ interface DevAccount {
password: string;
}
+interface OAuthProvider {
+ name: string;
+ display_name: string;
+}
+
interface Props {
allow_signup: boolean;
dev_accounts: DevAccount[];
login_redirect_url: string;
+ oauth_providers: OAuthProvider[];
}
function Login() {
- const { allow_signup, dev_accounts, login_redirect_url } = usePage<{ props: Props }>()
- .props as unknown as Props;
+ const { allow_signup, dev_accounts, login_redirect_url, oauth_providers } = usePage<{
+ props: Props;
+ }>().props as unknown as Props;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -156,6 +163,21 @@ function Login() {
)}
+ {oauth_providers && oauth_providers.length > 0 && (
+
+
+ Or continue with
+
+
+
+ )}
+
{dev_accounts && dev_accounts.length > 0 && (
diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py
index 64b20e03..21332c49 100644
--- a/modules/users/users/settings.py
+++ b/modules/users/users/settings.py
@@ -84,6 +84,21 @@ class UsersSettings(BaseSettings):
bootstrap_user_email: str = ""
bootstrap_user_password: str = ""
+ # OAuth / OIDC providers. Each provider is enabled by setting both client
+ # id and secret; missing credentials = provider not registered. Resolved
+ # at module-import time (env_str) because client secrets shouldn't ride
+ # in the DB-backed settings table that admins can read via the UI.
+ oauth_google_client_id: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_ID", "")
+ oauth_google_client_secret: str = env_str("SM_USERS_OAUTH_GOOGLE_CLIENT_SECRET", "")
+ oauth_github_client_id: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_ID", "")
+ oauth_github_client_secret: str = env_str("SM_USERS_OAUTH_GITHUB_CLIENT_SECRET", "")
+ # Generic OIDC — works with any provider that exposes a discovery URL
+ # (Keycloak, Authentik, Auth0, Zitadel, Entra ID, ...).
+ oauth_oidc_client_id: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_ID", "")
+ oauth_oidc_client_secret: str = env_str("SM_USERS_OAUTH_OIDC_CLIENT_SECRET", "")
+ oauth_oidc_discovery_url: str = env_str("SM_USERS_OAUTH_OIDC_DISCOVERY_URL", "")
+ oauth_oidc_display_name: str = env_str("SM_USERS_OAUTH_OIDC_DISPLAY_NAME", "OIDC")
+
@model_validator(mode="after")
def _forbid_placeholder_token_secrets_in_production(self) -> UsersSettings:
"""Fail boot if the reset/verify token secrets are still placeholders.