mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-31 01:08:03 +00:00
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:
co-authored by
Copilot Autofix powered by AI
parent
69afe7723f
commit
a205717171
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/') &&
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user