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 +

+
+ {oauth_providers.map((p) => ( + + ))} +
+
+ )} + {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.