diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 554401a61..b2033d373 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -142,6 +142,9 @@ INVITE_TOKEN_EXPIRY_SECONDS: Final[int] = safe_int( # OIDC OIDC_ENABLED: Final[bool] = safe_str_to_bool(_get_env("OIDC_ENABLED")) OIDC_AUTOLOGIN: Final[bool] = safe_str_to_bool(_get_env("OIDC_AUTOLOGIN")) +OIDC_ALLOW_REGISTRATION: Final[bool] = safe_str_to_bool( + _get_env("OIDC_ALLOW_REGISTRATION", "true") +) OIDC_PROVIDER: Final[str] = _get_env("OIDC_PROVIDER", "") OIDC_CLIENT_ID: Final[str] = _get_env("OIDC_CLIENT_ID", "") OIDC_CLIENT_SECRET: Final[str] = _get_env("OIDC_CLIENT_SECRET", "") diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 94c60bcf0..2b28987eb 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -13,6 +13,7 @@ from starlette.requests import HTTPConnection from config import ( INVITE_TOKEN_EXPIRY_SECONDS, + OIDC_ALLOW_REGISTRATION, OIDC_CLAIM_ROLES, OIDC_ENABLED, OIDC_ROLE_ADMIN, @@ -436,6 +437,15 @@ class OpenIDHandler: user = db_user_handler.get_user_by_email(email) if user is None: + if not OIDC_ALLOW_REGISTRATION: + log.error( + "User with email '%s' not found and OIDC registration is disabled", + hl(email, color=CYAN), + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User registration is disabled. Please contact an administrator to create an account.", + ) log.info( "User with email '%s' not found, creating new user", hl(email, color=CYAN), diff --git a/backend/tests/handler/auth/test_oidc.py b/backend/tests/handler/auth/test_oidc.py index a285cafa7..c75b3d364 100644 --- a/backend/tests/handler/auth/test_oidc.py +++ b/backend/tests/handler/auth/test_oidc.py @@ -23,6 +23,16 @@ def mock_oidc_enabled(mocker): mocker.patch("handler.auth.base_handler.OIDC_ENABLED", True) +@pytest.fixture +def mock_oidc_allow_registration_enabled(mocker): + mocker.patch("handler.auth.base_handler.OIDC_ALLOW_REGISTRATION", True) + + +@pytest.fixture +def mock_oidc_allow_registration_disabled(mocker): + mocker.patch("handler.auth.base_handler.OIDC_ALLOW_REGISTRATION", False) + + @pytest.fixture def mock_token(): return { @@ -171,6 +181,7 @@ async def test_oidc_valid_token_decoding( async def test_oidc_valid_add_user( mocker, mock_oidc_enabled, + mock_oidc_allow_registration_enabled, mock_token, mock_openid_configuration, config_override, @@ -218,6 +229,85 @@ async def test_oidc_valid_add_user( assert mock_add_user.call_args.args[0].role == romm_role +async def test_oidc_registration_disabled_rejects_unknown_user( + mocker, + mock_oidc_enabled, + mock_oidc_allow_registration_disabled, + mock_token, + mock_openid_configuration, +): + """Test that a login from an unknown email is rejected when registration is disabled.""" + mocker.patch( + "handler.database.db_user_handler.get_user_by_email", return_value=None + ) + mock_add_user = mocker.patch("handler.database.db_user_handler.add_user") + mocker.patch.object( + StarletteOAuth2App, + "load_server_metadata", + return_value=mock_openid_configuration, + ) + + oidc_handler = OpenIDHandler() + with pytest.raises(HTTPException, match="registration is disabled"): + await oidc_handler.get_current_active_user_from_openid_token(mock_token) + + mock_add_user.assert_not_called() + + +async def test_oidc_registration_enabled_creates_unknown_user( + mocker, + mock_oidc_enabled, + mock_oidc_allow_registration_enabled, + mock_token, + mock_openid_configuration, +): + """Test that a login from an unknown email creates a new user when registration is enabled.""" + mocker.patch( + "handler.database.db_user_handler.get_user_by_email", return_value=None + ) + mock_user = MagicMock(enabled=True, role=Role.VIEWER) + mock_add_user = mocker.patch( + "handler.database.db_user_handler.add_user", return_value=mock_user + ) + mocker.patch.object( + StarletteOAuth2App, + "load_server_metadata", + return_value=mock_openid_configuration, + ) + + oidc_handler = OpenIDHandler() + user, _ = await oidc_handler.get_current_active_user_from_openid_token(mock_token) + + mock_add_user.assert_called_once() + assert user == mock_user + + +async def test_oidc_registration_disabled_allows_existing_user( + mocker, + mock_oidc_enabled, + mock_oidc_allow_registration_disabled, + mock_token, + mock_openid_configuration, +): + """Test that an existing user can still log in when registration is disabled.""" + mock_user = MagicMock(enabled=True, role=Role.VIEWER) + mocker.patch( + "handler.database.db_user_handler.get_user_by_email", return_value=mock_user + ) + mock_add_user = mocker.patch("handler.database.db_user_handler.add_user") + mocker.patch.object( + StarletteOAuth2App, + "load_server_metadata", + return_value=mock_openid_configuration, + ) + + oidc_handler = OpenIDHandler() + user, _ = await oidc_handler.get_current_active_user_from_openid_token(mock_token) + + assert user == mock_user + mock_add_user.assert_not_called() + + async def test_oidc_valid_edit_user_role( mocker, mock_oidc_enabled, diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index c1622a3ee..68f67c14a 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1512,6 +1512,7 @@ Falls back to `FakeRedis` in test mode. | Variable | Default | Description | | ------------------------------- | -------------------- | ------------------------------- | | `OIDC_ENABLED` | `false` | Enable OpenID Connect | +| `OIDC_ALLOW_REGISTRATION` | `true` | Auto-create accounts on login | | `OIDC_PROVIDER` | | Provider URL | | `OIDC_CLIENT_ID` | | Client ID | | `OIDC_CLIENT_SECRET` | | Client secret | diff --git a/env.template b/env.template index 910e3d2bb..af6828cc2 100644 --- a/env.template +++ b/env.template @@ -38,6 +38,7 @@ DISABLE_LOGS_VIEWER=false # Disable the backend logs viewer # OpenID Connect OIDC_ENABLED=false # Enable OpenID Connect authentication OIDC_AUTOLOGIN=false # Skip the OIDC button on the login page and auto-redirect +OIDC_ALLOW_REGISTRATION=true # Allow new accounts to be created automatically on first OIDC login OIDC_PROVIDER= # Name of the OIDC provider in use OIDC_CLIENT_ID= # Client ID for OIDC authentication OIDC_CLIENT_SECRET= # Client secret for OIDC authentication