From a205717171894fa0da9e44b2558dc293ad811ccf Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 30 Aug 2026 10:32:54 +1000 Subject: [PATCH] SSO e2e testing (#12739) * Extend SSO auth workflows * SSO e2e testing workflow * Remove orphaned code * Add unit tests for SSO * improved error messaging * Add test for SSO registration disabled * Fix gating on LOGIN_ENABLE_SSO * Adjust UI state management * Add playwright test for SSO disabled * Add backend unit tests * Adjust API version * Additional playwright tests * Retain desired page state on login failure * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Adjust test code * Stricter matcher checking --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/backend/InvenTree/InvenTree/api.py | 2 + .../InvenTree/InvenTree/api_version.py | 5 +- .../InvenTree/InvenTree/auth_overrides.py | 10 + src/backend/InvenTree/InvenTree/sso.py | 54 --- src/backend/InvenTree/InvenTree/test_auth.py | 74 +++- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/lib/types/Auth.tsx | 7 +- src/frontend/package.json | 1 + src/frontend/playwright.config.ts | 41 +- src/frontend/playwright/mock-oidc-server.mjs | 46 ++ .../src/components/buttons/SSOButton.tsx | 8 +- src/frontend/src/functions/auth.tsx | 29 +- src/frontend/src/pages/Auth/LoggedIn.tsx | 37 +- .../src/pages/Auth/ProviderSignup.tsx | 149 +++++++ src/frontend/src/router.tsx | 4 + src/frontend/src/states/LocalState.tsx | 17 + src/frontend/src/states/ServerApiState.tsx | 3 + src/frontend/src/states/UserState.tsx | 8 +- src/frontend/src/states/states.tsx | 1 + src/frontend/tests/baseFixtures.ts | 1 + src/frontend/tests/defaults.ts | 19 + src/frontend/tests/pui_sso.spec.ts | 417 ++++++++++++++++++ src/frontend/yarn.lock | 31 ++ 23 files changed, 893 insertions(+), 72 deletions(-) create mode 100644 src/frontend/playwright/mock-oidc-server.mjs create mode 100644 src/frontend/src/pages/Auth/ProviderSignup.tsx create mode 100644 src/frontend/tests/pui_sso.spec.ts diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 261cc39f3b..dc73b939b9 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -230,6 +230,7 @@ class InfoApiSerializer(serializers.Serializer): class SettingsSerializer(serializers.Serializer): """Serializer for InfoApiSerializer.""" + sso_enabled = serializers.BooleanField() sso_registration = serializers.BooleanField() registration_enabled = serializers.BooleanField() password_forgotten_enabled = serializers.BooleanField() @@ -329,6 +330,7 @@ class InfoView(APIView): if (is_staff and settings.INVENTREE_ADMIN_ENABLED) else None, 'settings': { + 'sso_enabled': get_global_setting('LOGIN_ENABLE_SSO'), 'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'), 'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'), 'password_forgotten_enabled': get_global_setting( diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 3cef64b3a5..f4fbaf79c7 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 534 +INVENTREE_API_VERSION = 535 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v535 -> 2026-08-29 : https://github.com/inventree/InvenTree/pull/12739 + - Adds SSO registration API endpoint for creating new users via SSO + v534 -> 2026-08-21 : https://github.com/inventree/InvenTree/pull/12672 - rename 'tags' filter to 'tag_name' to avoid name clash with the 'tags' field on various API endpoints diff --git a/src/backend/InvenTree/InvenTree/auth_overrides.py b/src/backend/InvenTree/InvenTree/auth_overrides.py index 32de0107bd..bf590d0500 100644 --- a/src/backend/InvenTree/InvenTree/auth_overrides.py +++ b/src/backend/InvenTree/InvenTree/auth_overrides.py @@ -199,6 +199,16 @@ class CustomSocialAccountAdapter(RegistrationMixin, DefaultSocialAccountAdapter) REGISTRATION_SETTING = 'LOGIN_ENABLE_SSO_REG' + def pre_social_login(self, request, sociallogin): + """Reject SSO logins outright while SSO is disabled via settings. + + This runs for every social login attempt (new or existing account), + not just self-registration - LOGIN_ENABLE_SSO_REG only gates signup. + """ + if not get_global_setting('LOGIN_ENABLE_SSO'): + raise PermissionDenied('SSO is disabled') + super().pre_social_login(request, sociallogin) + def is_auto_signup_allowed(self, request, sociallogin): """Check if auto signup is enabled in settings.""" if get_global_setting('LOGIN_SIGNUP_SSO_AUTO', True): diff --git a/src/backend/InvenTree/InvenTree/sso.py b/src/backend/InvenTree/InvenTree/sso.py index d04abf21ba..fcc58d7d62 100644 --- a/src/backend/InvenTree/InvenTree/sso.py +++ b/src/backend/InvenTree/InvenTree/sso.py @@ -14,60 +14,6 @@ from common.settings import get_global_setting logger = structlog.get_logger('inventree') -def get_provider_app(provider): - """Return the SocialApp object for the given provider.""" - from allauth.socialaccount.models import SocialApp - - try: - apps = SocialApp.objects.filter(provider__iexact=provider.id) - except SocialApp.DoesNotExist: - logger.warning("SSO SocialApp not found for provider '%s'", provider.id) - return None - - if apps.count() > 1: - logger.warning("Multiple SocialApps found for provider '%s'", provider.id) - - if apps.count() == 0: - logger.warning("SSO SocialApp not found for provider '%s'", provider.id) - - return apps.first() - - -def check_provider(provider): - """Check if the given provider is correctly configured. - - To be correctly configured, the following must be true: - - - Provider must either have a registered SocialApp - - Must have at least one site enabled - """ - import allauth.app_settings - - # First, check that the provider is enabled - app = get_provider_app(provider) - - if not app: - return False - - if allauth.app_settings.SITES_ENABLED: - # At least one matching site must be specified - if not app.sites.exists(): - logger.error('SocialApp %s has no sites configured', app) - return False - - # At this point, we assume that the provider is correctly configured - return True - - -def provider_display_name(provider): - """Return the 'display name' for the given provider.""" - if app := get_provider_app(provider): - return app.name - - # Fallback value if app not found - return provider.name - - def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs): """Sync groups from IdP each time a SSO user logs on. diff --git a/src/backend/InvenTree/InvenTree/test_auth.py b/src/backend/InvenTree/InvenTree/test_auth.py index 465be13b64..5c50b38e01 100644 --- a/src/backend/InvenTree/InvenTree/test_auth.py +++ b/src/backend/InvenTree/InvenTree/test_auth.py @@ -1,8 +1,9 @@ """Test the sso and auth module functionality.""" +from django.conf import settings as django_settings from django.contrib.auth.models import Group, User -from django.core.exceptions import ValidationError -from django.test import override_settings +from django.core.exceptions import PermissionDenied, ValidationError +from django.test import RequestFactory, override_settings from django.test.testcases import TransactionTestCase from django.urls import reverse @@ -10,7 +11,7 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin from common.models import InvenTreeSetting from InvenTree import sso -from InvenTree.auth_overrides import RegistrationMixin +from InvenTree.auth_overrides import CustomSocialAccountAdapter, RegistrationMixin from InvenTree.unit_test import InvenTreeAPITestCase @@ -124,6 +125,61 @@ class TestSsoGroupSync(TransactionTestCase): self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1) +class TestSocialAccountAdapter(TransactionTestCase): + """Tests for CustomSocialAccountAdapter, used for all SSO logins.""" + + def setUp(self): + """Construct a fresh adapter for each test.""" + self.adapter = CustomSocialAccountAdapter() + + def test_pre_social_login_blocked_when_sso_disabled(self): + """SSO logins (new or existing accounts) must be rejected outright when SSO is disabled.""" + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', False) + with self.assertRaises(PermissionDenied): + self.adapter.pre_social_login(None, None) + + def test_pre_social_login_allowed_when_sso_enabled(self): + """A normal SSO login attempt should pass through untouched when SSO is enabled.""" + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', True) + # Should not raise - the default super() implementation is a no-op + self.adapter.pre_social_login(None, None) + + def test_is_auto_signup_allowed(self): + """Auto-signup must be blockable independently of whether SSO registration is open.""" + InvenTreeSetting.set_setting('LOGIN_SIGNUP_SSO_AUTO', False) + self.assertFalse(self.adapter.is_auto_signup_allowed(None, None)) + + # When enabled, defers to allauth's own default (SOCIALACCOUNT_AUTO_SIGNUP) + InvenTreeSetting.set_setting('LOGIN_SIGNUP_SSO_AUTO', True) + self.assertTrue(self.adapter.is_auto_signup_allowed(None, None)) + + def test_is_open_for_signup(self): + """SSO self-registration is gated by LOGIN_ENABLE_SSO_REG (and a configured mail backend).""" + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_REG', False) + self.assertFalse(self.adapter.is_open_for_signup(None, None)) + + with self.settings(EMAIL_HOST='localhost', TESTING_BYPASS_MAILCHECK=True): + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO_REG', True) + self.assertTrue(self.adapter.is_open_for_signup(None, None)) + + def test_get_connect_redirect_url(self): + """Connecting an SSO account should redirect back to the frontend root.""" + request = RequestFactory().get('/') + url = self.adapter.get_connect_redirect_url(request, None) + self.assertTrue(url.endswith(f'/{django_settings.FRONTEND_URL_BASE}/')) + + def test_authentication_error_does_not_raise(self): + """A provider-side authentication error should be logged, not raised further.""" + request = RequestFactory().get( + '/', data={'error': 'access_denied', 'error_description': 'Cancelled'} + ) + # Should not raise, regardless of whether error/exception are passed explicitly + self.adapter.authentication_error(request, 'mock') + self.adapter.authentication_error( + request, 'mock', error='denied', exception='User cancelled' + ) + + class EmailSettingsContext: """Context manager to enable email settings for tests.""" @@ -244,3 +300,15 @@ class TestAuth(InvenTreeAPITestCase): # Logged out user self.client.logout() self.get(url, expected_code=401) + + def test_server_info_sso_enabled(self): + """The server info endpoint should reflect the LOGIN_ENABLE_SSO setting.""" + url = reverse('api-inventree-info') + + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', True) + resp = self.get(url, expected_code=200) + self.assertTrue(resp.json()['settings']['sso_enabled']) + + InvenTreeSetting.set_setting('LOGIN_ENABLE_SSO', False) + resp = self.get(url, expected_code=200) + self.assertFalse(resp.json()['settings']['sso_enabled']) diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 1736c3deb9..844e86399a 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -41,6 +41,7 @@ export enum ApiEndpoints { auth_email_verify = 'auth/v1/auth/email/verify', auth_providers = 'auth/v1/account/providers', auth_provider_redirect = 'auth/v1/auth/provider/redirect', + auth_provider_signup = 'auth/v1/auth/provider/signup', auth_config = 'auth/v1/config', // Generic API endpoints diff --git a/src/frontend/lib/types/Auth.tsx b/src/frontend/lib/types/Auth.tsx index 50990f7ddc..3b645b2d7f 100644 --- a/src/frontend/lib/types/Auth.tsx +++ b/src/frontend/lib/types/Auth.tsx @@ -1,5 +1,4 @@ export interface AuthContext { - status: number; user?: { id: number; display: string; @@ -11,8 +10,7 @@ export interface AuthContext { at: number; username: string; }[]; - data: { flows: Flow[] }; - meta: { is_authenticated: boolean }; + flows?: Flow[]; } export enum FlowEnum { @@ -32,7 +30,8 @@ export enum FlowEnum { export interface Flow { id: FlowEnum; providers?: string[]; - is_pending?: boolean[]; + provider?: AuthProvider; + is_pending?: boolean; } export interface AuthProvider { diff --git a/src/frontend/package.json b/src/frontend/package.json index 975e3c6e6e..6012b0831d 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -129,6 +129,7 @@ "@vitejs/plugin-react": "^5", "babel-plugin-macros": "^3.1.0", "nyc": "^18.0.0", + "oauth2-mock-server": "^9.1.0", "otpauth": "^9.5.1", "path": "^0.12.7", "rollup": "^4.61.1", diff --git a/src/frontend/playwright.config.ts b/src/frontend/playwright.config.ts index 775f3c744c..2b903f67f6 100644 --- a/src/frontend/playwright.config.ts +++ b/src/frontend/playwright.config.ts @@ -1,5 +1,7 @@ import { defineConfig, devices } from '@playwright/test'; +import { mockOidcPort, mockSsoUser } from './tests/defaults'; + // Detect if running in CI const IS_CI = !!process.env.CI; @@ -79,6 +81,23 @@ export default defineConfig({ stderr: 'pipe', timeout: 120 * 1000 }, + // Mock OIDC provider - see tests/pui_sso.spec.ts + { + command: 'node ./playwright/mock-oidc-server.mjs', + env: { + MOCK_OIDC_PORT: String(mockOidcPort), + MOCK_OIDC_SUB: mockSsoUser.sub, + MOCK_OIDC_USERNAME: mockSsoUser.username, + MOCK_OIDC_EMAIL: mockSsoUser.email, + MOCK_OIDC_FIRST_NAME: mockSsoUser.firstName, + MOCK_OIDC_LAST_NAME: mockSsoUser.lastName + }, + url: `http://localhost:${mockOidcPort}/.well-known/openid-configuration`, + reuseExistingServer: IS_CI, + stdout: 'pipe', + stderr: 'pipe', + timeout: 60 * 1000 + }, { command: 'invoke dev.server -a 0.0.0.0:8000', env: { @@ -93,7 +112,27 @@ export default defineConfig({ INVENTREE_LOGIN_ATTEMPTS: '3', INVENTREE_PLUGINS_MANDATORY: 'samplelocate', INVENTREE_CUSTOM_SPLASH: 'img/playwright_custom_splash.png', - INVENTREE_CUSTOM_LOGO: 'img/playwright_custom_logo.png' + INVENTREE_CUSTOM_LOGO: 'img/playwright_custom_logo.png', + // Dummy mail config - only needed to satisfy the "is email + // configured" gate on registration/SSO-signup; nothing here ever + // needs to actually be delivered, and send failures are swallowed. + INVENTREE_EMAIL_HOST: 'localhost', + INVENTREE_EMAIL_SENDER: 'noreply@example.org', + // Register the mock OIDC provider from above as a real SSO backend + INVENTREE_SOCIAL_BACKENDS: 'openid_connect', + INVENTREE_SOCIAL_PROVIDERS: JSON.stringify({ + openid_connect: { + APPS: [ + { + provider_id: 'mock', + name: 'Mock SSO', + client_id: 'playwright-mock-client', + secret: 'playwright-mock-secret', + settings: { server_url: `http://localhost:${mockOidcPort}` } + } + ] + } + }) }, url: 'http://localhost:8000/api/', reuseExistingServer: IS_CI, diff --git a/src/frontend/playwright/mock-oidc-server.mjs b/src/frontend/playwright/mock-oidc-server.mjs new file mode 100644 index 0000000000..91a32f3a49 --- /dev/null +++ b/src/frontend/playwright/mock-oidc-server.mjs @@ -0,0 +1,46 @@ +/* + * A minimal OIDC provider, used only by the Playwright e2e suite to exercise + * real SSO logins without depending on an external identity provider. + * + * Started as a 'webServer' entry in playwright.config.ts. The identity it + * always authenticates as is configured via env vars (set from + * tests/defaults.ts's `mockSsoUser`, so the test spec and this process agree + * on the same values) - see tests/pui_sso.spec.ts. + * + * oauth2-mock-server auto-approves every /authorize request (no login UI), + * which is what makes this usable headlessly in CI. + */ +import { Events, OAuth2Server } from 'oauth2-mock-server'; + +const port = Number(process.env.MOCK_OIDC_PORT ?? 9950); +const host = process.env.MOCK_OIDC_HOST ?? 'localhost'; + +const claims = { + sub: process.env.MOCK_OIDC_SUB, + preferred_username: process.env.MOCK_OIDC_USERNAME, + email: process.env.MOCK_OIDC_EMAIL, + email_verified: true, + given_name: process.env.MOCK_OIDC_FIRST_NAME, + family_name: process.env.MOCK_OIDC_LAST_NAME +}; + +const server = new OAuth2Server(); +await server.issuer.keys.generate('RS256'); + +server.service.on(Events.BeforeTokenSigning, (token) => { + Object.assign(token.payload, claims); +}); + +server.service.on(Events.BeforeUserinfo, (userInfoResponse) => { + userInfoResponse.body = { ...claims }; +}); + +await server.start(port, host); +console.log(`Mock OIDC server listening at ${server.issuer.url}`); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, async () => { + await server.stop(); + process.exit(0); + }); +} diff --git a/src/frontend/src/components/buttons/SSOButton.tsx b/src/frontend/src/components/buttons/SSOButton.tsx index 892c4c8057..aaa7c983c5 100644 --- a/src/frontend/src/components/buttons/SSOButton.tsx +++ b/src/frontend/src/components/buttons/SSOButton.tsx @@ -34,6 +34,10 @@ const brandIcons: { [key: string]: JSX.Element } = { microsoft: }; +function getBrandIcon(provider: AuthProvider) { + return brandIcons[provider.id] || ; +} + export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) { return ( ) { + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 6c8596712a..ccb344ea03 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -146,6 +146,9 @@ export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout'))); export const Register = Loadable(lazy(() => import('./pages/Auth/Register'))); export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA'))); export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup'))); +export const ProviderSignup = Loadable( + lazy(() => import('./pages/Auth/ProviderSignup')) +); export const ChangePassword = Loadable( lazy(() => import('./pages/Auth/ChangePassword')) ); @@ -229,6 +232,7 @@ export const routes = ( } />, } />, } />, + } />, } /> } /> } /> diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index d8b40983bc..a5ba4d8581 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -71,6 +71,23 @@ export const useLocalState = create()( host = Object.values(state.hostList)[0].host; } + // hostList is only populated once DesktopAppView's mount effect has + // committed - callers that resolve the host earlier than that (e.g. + // SplashScreen's own mount-time fetchServerApiState() call) land + // here instead. Read the same underlying source directly, rather + // than falling back to window.location.origin, which is wrong + // whenever the frontend is served from a different origin than the + // backend (e.g. the vite dev server). + if (!host) { + const defaultKey = window.INVENTREE_SETTINGS?.default_server; + const settingsHost = defaultKey + ? window.INVENTREE_SETTINGS?.server_list?.[defaultKey]?.host + : undefined; + if (settingsHost) { + host = settingsHost; + } + } + // If no host is provided, fallback to using the current URL (default) if (!host) { host = window.location.origin; diff --git a/src/frontend/src/states/ServerApiState.tsx b/src/frontend/src/states/ServerApiState.tsx index 2d7a99e447..fdb036e2ab 100644 --- a/src/frontend/src/states/ServerApiState.tsx +++ b/src/frontend/src/states/ServerApiState.tsx @@ -89,6 +89,9 @@ export const useServerApiState = create()( set({ mfa_context }); }, sso_enabled: () => { + if (!get_server_setting(get().server?.settings?.sso_enabled)) { + return false; + } const data = get().auth_config?.socialaccount.providers; return !(data === undefined || data.length == 0); }, diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index c90c769ee7..531af229b2 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -7,6 +7,7 @@ import { apiUrl } from '@lib/functions/Api'; import type { UserProps, UserStateProps } from '@lib/types/User'; import { api, setApiDefaults } from '../App'; import { clearCsrfCookie } from '../functions/auth'; +import { useServerApiState } from './ServerApiState'; /** * Global user information state, using Zustand manager @@ -57,7 +58,12 @@ export const useUserState = create((set, get) => ({ get().setAuthenticated(false); } }) - .catch(() => { + .catch((err) => { + // Capture any pending auth flow (e.g. a pending SSO provider signup) + // reported alongside the failure, so callers can act on it. + if (err?.response?.data?.data) { + useServerApiState.getState().setAuthContext(err.response.data.data); + } get().setAuthenticated(false); }); }, diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 667465a404..e83b347867 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -28,6 +28,7 @@ export interface ServerAPIProps { default_locale: null | string; django_admin: null | string; settings: { + sso_enabled: null | boolean; sso_registration: null | boolean; registration_enabled: null | boolean; password_forgotten_enabled: null | boolean; diff --git a/src/frontend/tests/baseFixtures.ts b/src/frontend/tests/baseFixtures.ts index 74a83d14c9..389c612572 100644 --- a/src/frontend/tests/baseFixtures.ts +++ b/src/frontend/tests/baseFixtures.ts @@ -129,6 +129,7 @@ export const test = baseTest.extend<{}, {}>({ !url.includes('/api/user/me/token/') && !url.includes('/api/auth/v1/auth/login') && !url.includes('/api/auth/v1/auth/session') && + !url.includes('/api/auth/v1/auth/provider/signup') && !url.includes('/api/auth/v1/account/authenticators/totp') && !url.includes('/api/auth/v1/account/password/change') && !url.includes('/api/barcode/') && diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index e27794a7c5..d806d86a78 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -43,3 +43,22 @@ export const engineeruser: UserType = { username: 'engineer', testcred: 'partsonly' }; + +export const mockOidcPort = 9950; +export const mockOidcUrl = `http://localhost:${mockOidcPort}`; + +/* + * Identity always returned by the mock OIDC provider used in pui_sso.spec.ts + * (see playwright/mock-oidc-server.mjs). It has no matching InvenTree + * account, so logging in with it exercises the pending 'provider_signup' + * flow. Consumed both by playwright.config.ts (to configure the mock + * server's env and the backend's SSO provider settings) and by the test spec + * itself, so both sides agree on the same values. + */ +export const mockSsoUser = { + sub: 'mock-oidc-user-1', + username: 'ssotestuser', + email: 'ssotestuser@example.org', + firstName: 'Sso', + lastName: 'Testuser' +}; diff --git a/src/frontend/tests/pui_sso.spec.ts b/src/frontend/tests/pui_sso.spec.ts new file mode 100644 index 0000000000..87b956e89d --- /dev/null +++ b/src/frontend/tests/pui_sso.spec.ts @@ -0,0 +1,417 @@ +import { createApi } from './api.js'; +import { expect, test } from './baseFixtures.js'; +import { apiUrl, logoutUrl, mockSsoUser } from './defaults.js'; +import { navigate } from './helpers.js'; +import { setSettingState } from './settings.js'; + +/* + * End-to-end coverage for logging in via SSO as a brand-new user. + * + * django-allauth leaves this scenario as a pending 'provider_signup' auth + * flow rather than a completed login - see checkLoginState() in + * src/functions/auth.tsx and src/pages/Auth/ProviderSignup.tsx. This test + * drives the real SSO handshake against the mock OIDC provider started in + * playwright.config.ts (playwright/mock-oidc-server.mjs), rather than + * stubbing network responses, so it actually exercises the backend's + * authorization-code exchange and pending-flow logic, not just the new + * frontend page in isolation. + */ +// Every throwaway username any test in this file creates - kept in one +// place so cleanup can't miss one. +const TEST_USERNAMES = [ + mockSsoUser.username, + 'existingemailuser', + 'ssoconnecttest' +]; + +async function findUserByUsername(username: string): Promise { + const api = await createApi({}); + const data = await api + .get(`user/?search=${encodeURIComponent(username)}`) + .then((response) => response.json()); + // user/ is unpaginated by default (no PAGE_SIZE configured) - returns a + // plain array - but fall back to a { results: [...] } envelope too, in + // case pagination is ever turned on for it. + const users = Array.isArray(data) ? data : (data?.results ?? []); + return users.find((user: any) => user.username === username); +} + +async function deleteUserByUsername(username: string) { + const existing = await findUserByUsername(username); + if (existing) { + const api = await createApi({}); + await api.delete(`user/${existing.pk}/`); + } +} + +// Remove any account left over from a previous run - including one that +// failed partway, before its own cleanup could execute (a Playwright test +// timeout aborts the test function rather than waiting for an in-flight +// `finally` block to complete). An existing SocialAccount link in +// particular makes allauth log straight in instead of hitting the pending +// 'provider_signup' flow most of these tests exist to cover - and a leftover +// local user makes the next run's own creation of it fail outright. +async function cleanupTestUsers() { + for (const username of TEST_USERNAMES) { + await deleteUserByUsername(username); + } +} + +test.beforeEach(cleanupTestUsers); +test.afterEach(cleanupTestUsers); + +/* + * Create a throwaway local (non-SSO) user for tests that need one already + * sitting in the database - e.g. an existing account colliding with the + * mock SSO identity's username/email. Admin-created users get a random + * password, so a known one is set separately wherever a test needs to log + * in as this user directly. + */ +async function createLocalUser({ + username, + email, + password +}: { + username: string; + email: string; + password?: string; +}) { + const api = await createApi({}); + const user = await api + .post('user/', { + data: { username, email, first_name: 'Test', last_name: 'Fixture' } + }) + .then((response) => response.json()); + + if (password) { + await api.patch(`user/${user.pk}/set-password/`, { + data: { password, override_warning: true } + }); + } + + return user; +} + +async function deleteUser(pk: number) { + const api = await createApi({}); + await api.delete(`user/${pk}/`); +} + +test('SSO - Complete Registration', async ({ page }) => { + // Allow SSO self-registration, but disable auto-signup - this is what + // forces a brand-new SSO identity into the pending 'provider_signup' flow + // instead of silently creating (or rejecting) an account. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: false }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + // Follow the real redirect out to the mock IdP and back + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // No matching local account exists - the frontend should route to the + // registration-completion page instead of bouncing back to '/login' + await page.waitForURL('**/web/provider-signup'); + + // Suggested username/email are prefilled from the mock IdP's claims + await expect(page.getByLabel('provider-signup-username')).toHaveValue( + mockSsoUser.username + ); + await expect(page.getByLabel('provider-signup-email')).toHaveValue( + mockSsoUser.email + ); + + await page.getByRole('button', { name: 'Complete Registration' }).click(); + + // Registration completes, and the user is logged straight in + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + await page + .getByRole('button', { + name: `${mockSsoUser.firstName} ${mockSsoUser.lastName}` + }) + .waitFor(); +}); + +test('SSO - Registration Disabled', async ({ page }) => { + // Allow SSO login, but disable self-registration - a brand-new SSO + // identity has no matching local account, and nothing should silently + // create one. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: false }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // django-allauth rejects the pending signup server-side (raises + // SignupClosedException) before a 'provider_signup' flow is ever + // recorded, so the frontend never reaches '/provider-signup' here - it + // lands back on '/logged-in' with an 'error' query param appended (see + // on_authentication_error() in allauth/headless/socialaccount/internal.py), + // which LoggedIn.tsx must surface as a visible error instead of silently + // bouncing back to a blank login page. + await page.waitForURL('**/web/login'); + await page.getByText('SSO Login Failed').waitFor(); + await page.getByText('Registration via SSO is currently disabled.').waitFor(); + + // No account should have been created + expect(await findUserByUsername(mockSsoUser.username)).toBeUndefined(); +}); + +test('SSO - Disabled', async ({ page }) => { + // Master switch off, regardless of registration settings - the button + // should disappear, and a direct attempt at the redirect endpoint + // (bypassing the now-hidden button) must still be rejected server-side + // by CustomSocialAccountAdapter.pre_social_login(), not just hidden + // client-side. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: false }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await expect(page.getByRole('button', { name: 'Mock SSO' })).toBeHidden(); + + await page.evaluate(async (apiBase) => { + // Populate the CSRF cookie, then submit the same redirect form + // ProviderLogin() would have, driving the flow the hidden button would + // otherwise start. + await fetch(`${apiBase}auth/v1/auth/session`, { credentials: 'include' }); + const csrftoken = document.cookie + .split('; ') + .find((row) => row.startsWith('csrftoken=')) + ?.split('=')[1]; + + const form = document.createElement('form'); + form.method = 'post'; + form.action = `${apiBase}auth/v1/auth/provider/redirect`; + const fields: Record = { + provider: 'mock', + callback_url: `${window.location.origin}/web/logged-in`, + process: 'login', + csrfmiddlewaretoken: csrftoken ?? '' + }; + for (const [key, value] of Object.entries(fields)) { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); + }, apiUrl); + + await page.waitForURL('**/web/login'); + await page.getByText('SSO Login Failed').waitFor(); + await page + .getByText('You do not have permission to log in this way.') + .waitFor(); +}); + +test('SSO - Provider Signup Page With No Pending Signup', async ({ page }) => { + // Loading '/provider-signup' with no pending signup in the session - e.g. + // a direct visit, a bookmark, or a reload after the session has expired - + // used to silently bounce back to a blank '/login' with zero explanation + // (the server correctly returns 409, but the page ignored the status and + // just navigated away). Regression test for that fix. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await navigate(page, 'provider-signup', { waitUntil: 'load' }); + + await page.waitForURL('**/web/login'); + await page.getByText('Registration Failed').waitFor(); + await page + .getByText( + 'Your SSO sign-in session has expired. Please try logging in again.' + ) + .waitFor(); +}); + +test('SSO - Auto Signup', async ({ page }) => { + // LOGIN_SIGNUP_SSO_AUTO's default value - a brand-new SSO identity should + // be signed up and logged in automatically, with no confirmation step at + // all. This is the most common real-world path, and the opposite of + // 'SSO - Complete Registration' above (which explicitly disables this). + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: true }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // Straight through to the dashboard - never touches '/provider-signup' + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + await page + .getByRole('button', { + name: `${mockSsoUser.firstName} ${mockSsoUser.lastName}` + }) + .waitFor(); + + // The account was created using the claims from the mock IdP + const created = await findUserByUsername(mockSsoUser.username); + expect(created).toBeDefined(); + expect(created.email).toEqual(mockSsoUser.email); +}); + +test('SSO - Existing User Login', async ({ page }) => { + // A second login as the same SSO identity should go straight through - + // the SocialAccount is already linked from the first login, so none of + // the new-user signup logic (or its own 'auto signup' gate) applies. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: true }); + + // First login creates and links the account. Waiting for 'navigation-menu' + // (not just the URL) matters here - '/\/web(\/home)?/' is unanchored and + // also matches the transient '/web/logged-in' stop along the way, so a + // bare waitForURL can resolve before the login has actually settled, + // racing the setSettingState() call right after it. + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + await page.getByRole('button', { name: 'Mock SSO' }).click(); + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + + // Disable auto-signup entirely - if this second login were mistakenly + // treated as a new signup, it would now hit '/provider-signup' instead + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: false }); + + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // Straight through again - no confirmation step for an already-linked account + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); +}); + +test('SSO - Existing Local Account With Matching Email', async ({ page }) => { + // A separate local account with the SAME email as the SSO identity, but + // no linked SocialAccount, must not be silently merged into or + // duplicated - django-allauth surfaces a clear validation error instead, + // asking the user to log in normally and connect the SSO account there. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: false }); + + const existing = await createLocalUser({ + username: 'existingemailuser', + email: mockSsoUser.email + }); + + try { + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // Still treated as a pending new signup - no auto-link by email + await page.waitForURL('**/web/provider-signup'); + await page.getByRole('button', { name: 'Complete Registration' }).click(); + + await page + .getByText( + 'An account already exists with this email address. Please sign in to that account first, then connect your Mock SSO account.' + ) + .waitFor(); + + // No second/duplicate account was created + expect(await findUserByUsername(mockSsoUser.username)).toBeUndefined(); + } finally { + await deleteUser(existing.pk); + } +}); + +test('SSO - Username Collision On Signup', async ({ page }) => { + // The suggested username from the IdP collides with a different, + // unrelated existing user - the signup form must surface that as a field + // error rather than crashing or silently failing. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + await setSettingState({ setting: 'LOGIN_ENABLE_SSO_REG', value: true }); + await setSettingState({ setting: 'LOGIN_SIGNUP_SSO_AUTO', value: false }); + + const existing = await createLocalUser({ + username: mockSsoUser.username, + email: 'someoneelse@example.org' + }); + + try { + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + await page.getByRole('button', { name: 'Mock SSO' }).click(); + await page.waitForURL('**/web/provider-signup'); + + // The suggested username is still prefilled, even though it collides + await expect(page.getByLabel('provider-signup-username')).toHaveValue( + mockSsoUser.username + ); + + await page.getByRole('button', { name: 'Complete Registration' }).click(); + await page.getByText('A user with that username already exists.').waitFor(); + + // Still on the signup page - no account was created or logged into + await expect(page).toHaveURL(/\/web\/provider-signup/); + } finally { + await deleteUser(existing.pk); + } +}); + +test('SSO - Connect Provider To Existing Account', async ({ page }) => { + // An already-logged-in (non-SSO) user can link an SSO provider to their + // account from Account Settings > Security - a separate entry point + // (ProviderLogin(provider, 'connect')) from the login page's button. + await setSettingState({ setting: 'LOGIN_ENABLE_SSO', value: true }); + + const password = 'Test-Password-1234!'; + const user = await createLocalUser({ + username: 'ssoconnecttest', + email: 'ssoconnecttest@example.org', + password + }); + + try { + await navigate(page, logoutUrl, { waitUntil: 'load' }); + await page.waitForURL('**/web/login'); + await page.getByLabel('login-username').fill(user.username); + await page.getByLabel('login-password').fill(password); + await page.getByRole('button', { name: 'Log In' }).click(); + // '/\/web(\/home)?/' is unanchored, so it also matches the transient + // '/web/logged-in' stop along the way - wait for 'navigation-menu' too, + // so the following navigate() isn't racing a still-settling login. + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + + await navigate(page, 'settings/user/security', { + waitUntil: 'networkidle' + }); + await page.getByText('Single Sign On').click(); + await page.getByRole('button', { name: 'Mock SSO' }).click(); + + // Real redirect out to the mock IdP and back - lands on the dashboard, + // not back on the settings page (get_connect_redirect_url() always + // returns the frontend root) + await page.waitForURL(/\/web(\/home)?/); + await page.getByRole('button', { name: 'navigation-menu' }).waitFor(); + + // Confirm the provider now shows as connected to this account + await navigate(page, 'settings/user/security', { + waitUntil: 'networkidle' + }); + await page.getByText('Single Sign On').click(); + await page.getByText(`Mock SSO: ${mockSsoUser.email}`).waitFor(); + } finally { + // Deleting the user cascades away the SocialAccount link too, so the + // mock identity is unlinked again for other tests + await deleteUser(user.pk); + } +}); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 29ec1d01dc..7ed378cab8 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2158,6 +2158,13 @@ baseline-browser-mapping@^2.10.12: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042" integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw== +basic-auth@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + before-after-hook@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9" @@ -3188,6 +3195,11 @@ is-observable@^2.1.0: resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-2.1.0.tgz#5c8d733a0b201c80dff7bb7c0df58c6a255c7c69" integrity sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw== +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -3307,6 +3319,11 @@ jiti@^2.5.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64" integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ== +jose@^6.2.3: + version "6.2.10" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.10.tgz#b70436c920c4b3f97314c28a8b8612c15b1d9e7f" + integrity sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g== + js-sha256@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d" @@ -3630,6 +3647,15 @@ nyc@^18.0.0: test-exclude "^8.0.0" yargs "^15.0.2" +oauth2-mock-server@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/oauth2-mock-server/-/oauth2-mock-server-9.1.0.tgz#920d2c2f90c796131e98675aa2e229c1db0d602e" + integrity sha512-1Aug6KQhD9IoxyCogFb0XQqovSOhkvOSRUz5Zm98o96H4omt1HEbUMxwzgwl7GO42eh+BqBH/uCbcKIxi4OBbg== + dependencies: + basic-auth "^2.0.1" + is-plain-obj "^4.1.0" + jose "^6.2.3" + object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -4265,6 +4291,11 @@ rollup@^4.43.0, rollup@^4.61.1: "@rollup/rollup-win32-x64-msvc" "4.62.4" fsevents "~2.3.2" +safe-buffer@5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"