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 (
) {
);
}
-function getBrandIcon(provider: AuthProvider) {
- return brandIcons[provider.id] || ;
-}
diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx
index 5e2068583d..5ae4c0641d 100644
--- a/src/frontend/src/functions/auth.tsx
+++ b/src/frontend/src/functions/auth.tsx
@@ -5,6 +5,7 @@ import {
} from '@github/webauthn-json/browser-ponyfill';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
+import { getBaseUrl } from '@lib/functions/Navigation';
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
import { t } from '@lingui/core/macro';
import { notifications, showNotification } from '@mantine/notifications';
@@ -481,12 +482,24 @@ export const checkLoginState = async (
await loginSuccess();
} else if (!no_redirect) {
setLoginChecked(true);
- navigate('/login', { state: redirect });
+
+ // A user authenticated via SSO, but with no matching local account, is
+ // left by the server in a pending 'provider_signup' flow rather than
+ // being logged in - route them to finish registration instead of
+ // silently bouncing them back to the login page.
+ const { auth_context } = useServerApiState.getState();
+ const providerSignupPending = auth_context?.flows?.some(
+ (flow: any) => flow.id == FlowEnum.ProviderSignup && flow.is_pending
+ );
+
+ navigate(providerSignupPending ? '/provider-signup' : '/login', {
+ state: redirect
+ });
}
setLoginChecked(true);
};
-function handleSuccessFullAuth(
+export function handleSuccessFullAuth(
response: any,
navigate: NavigateFunction,
location?: Location,
@@ -546,7 +559,17 @@ export async function ProviderLogin(
await ensureCsrf();
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
provider: provider.id,
- callback_url: generateUrl('/logged-in'),
+ // Return to wherever this page is actually being served from, not the
+ // configured API host - the two differ whenever the frontend and
+ // backend are hosted separately (e.g. the vite dev server, or a
+ // decoupled-frontend deployment). The '/web' base is normally added
+ // back in server-side by Django's compatibility redirect for bare
+ // (non-API-host) paths, which only exists on the Django side - so add
+ // it explicitly rather than relying on that.
+ callback_url: generateUrl(
+ `/${getBaseUrl()}/logged-in`,
+ window.location.origin
+ ),
process: process,
csrfmiddlewaretoken: getCsrfCookie()
});
diff --git a/src/frontend/src/pages/Auth/LoggedIn.tsx b/src/frontend/src/pages/Auth/LoggedIn.tsx
index 5907efc889..8bae4d78ae 100644
--- a/src/frontend/src/pages/Auth/LoggedIn.tsx
+++ b/src/frontend/src/pages/Auth/LoggedIn.tsx
@@ -1,15 +1,50 @@
import { t } from '@lingui/core/macro';
import { useEffect } from 'react';
-import { useLocation, useNavigate } from 'react-router-dom';
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { checkLoginState } from '../../functions/auth';
+import { showLoginNotification } from '../../functions/notifications';
import { Wrapper } from './Layout';
+// Maps the 'error' query param allauth appends to this callback URL when an
+// SSO login attempt fails server-side (e.g. registration is closed, or the
+// user cancelled at the provider) - see on_authentication_error() in
+// allauth/headless/socialaccount/internal.py.
+function ssoErrorMessage(error: string): string {
+ switch (error) {
+ case 'signup_closed':
+ return t`Registration via SSO is currently disabled.`;
+ case 'cancelled':
+ return t`Login was cancelled.`;
+ case 'denied':
+ return t`Access was denied by the identity provider.`;
+ case 'permission_denied':
+ return t`You do not have permission to log in this way.`;
+ case 'reauthentication_required':
+ return t`You need to reauthenticate to continue.`;
+ default:
+ return t`An error occurred during SSO login (${error}).`;
+ }
+}
+
export default function Logged_In() {
const navigate = useNavigate();
const location = useLocation();
+ const [searchParams] = useSearchParams();
useEffect(() => {
+ const error = searchParams.get('error');
+
+ if (error) {
+ showLoginNotification({
+ title: t`SSO Login Failed`,
+ message: ssoErrorMessage(error),
+ success: false
+ });
+ navigate('/login', { state: location?.state });
+ return;
+ }
+
checkLoginState(navigate, location?.state);
}, [navigate]);
diff --git a/src/frontend/src/pages/Auth/ProviderSignup.tsx b/src/frontend/src/pages/Auth/ProviderSignup.tsx
new file mode 100644
index 0000000000..013a3d64ed
--- /dev/null
+++ b/src/frontend/src/pages/Auth/ProviderSignup.tsx
@@ -0,0 +1,149 @@
+import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
+import { apiUrl } from '@lib/functions/Api';
+import { t } from '@lingui/core/macro';
+import { Trans } from '@lingui/react/macro';
+import { Alert, Button, Group, Stack, Text, TextInput } from '@mantine/core';
+import { useForm } from '@mantine/form';
+import { useEffect, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { api } from '../../App';
+import { handleSuccessFullAuth } from '../../functions/auth';
+import { showLoginNotification } from '../../functions/notifications';
+import { Wrapper } from './Layout';
+
+/*
+ * Completes an SSO login for a user with no matching local account.
+ *
+ * The server parks these as a pending 'provider_signup' auth flow rather
+ * than logging the user in - see checkLoginState() in functions/auth.tsx,
+ * which routes here when that flow is detected.
+ */
+export default function ProviderSignup() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const form = useForm({ initialValues: { username: '', email: '' } });
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [providerName, setProviderName] = useState('');
+ const [formError, setFormError] = useState(undefined);
+
+ useEffect(() => {
+ api
+ .get(apiUrl(ApiEndpoints.auth_provider_signup))
+ .then((response) => {
+ const data = response.data?.data ?? {};
+ // Suggested email addresses come back as their own list (not on
+ // `user`) - prefer the primary one, falling back to the first.
+ const emails = data?.email ?? [];
+ const email = emails.find((e: any) => e.primary) ?? emails[0];
+ form.setValues({
+ username: data?.user?.username ?? '',
+ email: email?.email ?? ''
+ });
+ setProviderName(data?.account?.provider?.name ?? t`your provider`);
+ setLoading(false);
+ })
+ .catch((err) => {
+ // Whatever the reason, there is no pending signup left to complete -
+ // always show *something* rather than silently bouncing back to a
+ // blank login page (the exact failure mode this page exists to fix).
+ if (err?.response?.status === 403) {
+ showLoginNotification({
+ title: t`Registration Failed`,
+ message: t`SSO registration is currently disabled.`,
+ success: false
+ });
+ } else if (err?.response?.status === 409) {
+ // No pending signup in the session - e.g. this page was loaded
+ // directly, or the signup session has since expired.
+ showLoginNotification({
+ title: t`Registration Failed`,
+ message: t`Your SSO sign-in session has expired. Please try logging in again.`,
+ success: false
+ });
+ } else {
+ showLoginNotification({
+ title: t`Registration Failed`,
+ message: t`An error occurred while completing SSO registration. Please try logging in again.`,
+ success: false
+ });
+ }
+ navigate('/login', { state: location?.state });
+ });
+ }, []);
+
+ function handleSubmit() {
+ setFormError(undefined);
+ setSubmitting(true);
+
+ api
+ .post(apiUrl(ApiEndpoints.auth_provider_signup), form.values, {
+ headers: { Authorization: '' }
+ })
+ .then((response) => {
+ handleSuccessFullAuth(response, navigate, location);
+ })
+ .catch((err) => {
+ setSubmitting(false);
+
+ const errors = err.response?.data?.errors;
+ if (Array.isArray(errors)) {
+ for (const e of errors) {
+ if (
+ e.param &&
+ Object.prototype.hasOwnProperty.call(form.values, e.param)
+ ) {
+ form.setFieldError(e.param, e.message);
+ } else {
+ setFormError(e.message);
+ }
+ }
+ } else {
+ setFormError(t`Check your input and try again.`);
+ }
+ });
+ }
+
+ if (loading) {
+ return ;
+ }
+
+ 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"