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>
This commit is contained in:
Oliver
2026-08-30 10:32:54 +10:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 69afe7723f
commit a205717171
23 changed files with 893 additions and 72 deletions
+2
View File
@@ -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(
@@ -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
@@ -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):
-54
View File
@@ -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.
+71 -3
View File
@@ -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'])
+1
View File
@@ -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
+3 -4
View File
@@ -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 {
+1
View File
@@ -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",
+40 -1
View File
@@ -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,
@@ -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);
});
}
@@ -34,6 +34,10 @@ const brandIcons: { [key: string]: JSX.Element } = {
microsoft: <IconBrandAzure />
};
function getBrandIcon(provider: AuthProvider) {
return brandIcons[provider.id] || <IconLogin />;
}
export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
return (
<Tooltip
@@ -42,7 +46,6 @@ export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
<Button
leftSection={getBrandIcon(provider)}
radius='xl'
component='a'
onClick={() => ProviderLogin(provider)}
>
{provider.name}
@@ -50,6 +53,3 @@ export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
</Tooltip>
);
}
function getBrandIcon(provider: AuthProvider) {
return brandIcons[provider.id] || <IconLogin />;
}
+26 -3
View File
@@ -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<any>,
@@ -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()
});
+36 -1
View File
@@ -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]);
@@ -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<string>('');
const [formError, setFormError] = useState<string | undefined>(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 <Wrapper titleText={t`Complete Your Registration`} loader />;
}
return (
<Wrapper titleText={t`Complete Your Registration`} logOff>
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap={0}>
<Text size='sm' c='dimmed' mb='md'>
<Trans>
You have signed in with {providerName}, but no matching account
exists yet. Confirm your details below to create one.
</Trans>
</Text>
{formError && (
<Alert color='red' mb='md'>
{formError}
</Alert>
)}
<TextInput
required
label={t`Username`}
aria-label='provider-signup-username'
placeholder={t`Your username`}
{...form.getInputProps('username')}
/>
<TextInput
label={t`Email`}
aria-label='provider-signup-email'
placeholder='email@example.org'
{...form.getInputProps('email')}
/>
</Stack>
<Group justify='space-between' mt='xl'>
<Button type='submit' disabled={submitting} fullWidth>
<Trans>Complete Registration</Trans>
</Button>
</Group>
</form>
</Wrapper>
);
}
+4
View File
@@ -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 = (
<Route path='/register' element={<Register />} />,
<Route path='/mfa' element={<Mfa />} />,
<Route path='/mfa-setup' element={<MfaSetup />} />,
<Route path='/provider-signup' element={<ProviderSignup />} />,
<Route path='/change-password' element={<ChangePassword />} />
<Route path='/reset-password' element={<Reset />} />
<Route path='/set-password' element={<ResetPassword />} />
+17
View File
@@ -71,6 +71,23 @@ export const useLocalState = create<LocalStateProps>()(
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;
@@ -89,6 +89,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
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);
},
+7 -1
View File
@@ -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<UserStateProps>((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);
});
},
+1
View File
@@ -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;
+1
View File
@@ -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/') &&
+19
View File
@@ -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'
};
+417
View File
@@ -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<any> {
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<string, string> = {
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);
}
});
+31
View File
@@ -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"