mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-31 17:28:07 +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):
|
class SettingsSerializer(serializers.Serializer):
|
||||||
"""Serializer for InfoApiSerializer."""
|
"""Serializer for InfoApiSerializer."""
|
||||||
|
|
||||||
|
sso_enabled = serializers.BooleanField()
|
||||||
sso_registration = serializers.BooleanField()
|
sso_registration = serializers.BooleanField()
|
||||||
registration_enabled = serializers.BooleanField()
|
registration_enabled = serializers.BooleanField()
|
||||||
password_forgotten_enabled = serializers.BooleanField()
|
password_forgotten_enabled = serializers.BooleanField()
|
||||||
@@ -329,6 +330,7 @@ class InfoView(APIView):
|
|||||||
if (is_staff and settings.INVENTREE_ADMIN_ENABLED)
|
if (is_staff and settings.INVENTREE_ADMIN_ENABLED)
|
||||||
else None,
|
else None,
|
||||||
'settings': {
|
'settings': {
|
||||||
|
'sso_enabled': get_global_setting('LOGIN_ENABLE_SSO'),
|
||||||
'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'),
|
'sso_registration': registration_enabled('LOGIN_ENABLE_SSO_REG'),
|
||||||
'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'),
|
'registration_enabled': registration_enabled('LOGIN_ENABLE_REG'),
|
||||||
'password_forgotten_enabled': get_global_setting(
|
'password_forgotten_enabled': get_global_setting(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
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
|
- 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'
|
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):
|
def is_auto_signup_allowed(self, request, sociallogin):
|
||||||
"""Check if auto signup is enabled in settings."""
|
"""Check if auto signup is enabled in settings."""
|
||||||
if get_global_setting('LOGIN_SIGNUP_SSO_AUTO', True):
|
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')
|
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):
|
def ensure_sso_groups(sender, sociallogin: SocialLogin, **kwargs):
|
||||||
"""Sync groups from IdP each time a SSO user logs on.
|
"""Sync groups from IdP each time a SSO user logs on.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""Test the sso and auth module functionality."""
|
"""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.contrib.auth.models import Group, User
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.test import override_settings
|
from django.test import RequestFactory, override_settings
|
||||||
from django.test.testcases import TransactionTestCase
|
from django.test.testcases import TransactionTestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
|
|||||||
|
|
||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree import sso
|
from InvenTree import sso
|
||||||
from InvenTree.auth_overrides import RegistrationMixin
|
from InvenTree.auth_overrides import CustomSocialAccountAdapter, RegistrationMixin
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +125,61 @@ class TestSsoGroupSync(TransactionTestCase):
|
|||||||
self.assertEqual(Group.objects.filter(name='inventree_group').count(), 1)
|
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:
|
class EmailSettingsContext:
|
||||||
"""Context manager to enable email settings for tests."""
|
"""Context manager to enable email settings for tests."""
|
||||||
|
|
||||||
@@ -244,3 +300,15 @@ class TestAuth(InvenTreeAPITestCase):
|
|||||||
# Logged out user
|
# Logged out user
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
self.get(url, expected_code=401)
|
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_email_verify = 'auth/v1/auth/email/verify',
|
||||||
auth_providers = 'auth/v1/account/providers',
|
auth_providers = 'auth/v1/account/providers',
|
||||||
auth_provider_redirect = 'auth/v1/auth/provider/redirect',
|
auth_provider_redirect = 'auth/v1/auth/provider/redirect',
|
||||||
|
auth_provider_signup = 'auth/v1/auth/provider/signup',
|
||||||
auth_config = 'auth/v1/config',
|
auth_config = 'auth/v1/config',
|
||||||
|
|
||||||
// Generic API endpoints
|
// Generic API endpoints
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
status: number;
|
|
||||||
user?: {
|
user?: {
|
||||||
id: number;
|
id: number;
|
||||||
display: string;
|
display: string;
|
||||||
@@ -11,8 +10,7 @@ export interface AuthContext {
|
|||||||
at: number;
|
at: number;
|
||||||
username: string;
|
username: string;
|
||||||
}[];
|
}[];
|
||||||
data: { flows: Flow[] };
|
flows?: Flow[];
|
||||||
meta: { is_authenticated: boolean };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FlowEnum {
|
export enum FlowEnum {
|
||||||
@@ -32,7 +30,8 @@ export enum FlowEnum {
|
|||||||
export interface Flow {
|
export interface Flow {
|
||||||
id: FlowEnum;
|
id: FlowEnum;
|
||||||
providers?: string[];
|
providers?: string[];
|
||||||
is_pending?: boolean[];
|
provider?: AuthProvider;
|
||||||
|
is_pending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProvider {
|
export interface AuthProvider {
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
"@vitejs/plugin-react": "^5",
|
"@vitejs/plugin-react": "^5",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
"nyc": "^18.0.0",
|
"nyc": "^18.0.0",
|
||||||
|
"oauth2-mock-server": "^9.1.0",
|
||||||
"otpauth": "^9.5.1",
|
"otpauth": "^9.5.1",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"rollup": "^4.61.1",
|
"rollup": "^4.61.1",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
import { mockOidcPort, mockSsoUser } from './tests/defaults';
|
||||||
|
|
||||||
// Detect if running in CI
|
// Detect if running in CI
|
||||||
const IS_CI = !!process.env.CI;
|
const IS_CI = !!process.env.CI;
|
||||||
|
|
||||||
@@ -79,6 +81,23 @@ export default defineConfig({
|
|||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
timeout: 120 * 1000
|
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',
|
command: 'invoke dev.server -a 0.0.0.0:8000',
|
||||||
env: {
|
env: {
|
||||||
@@ -93,7 +112,27 @@ export default defineConfig({
|
|||||||
INVENTREE_LOGIN_ATTEMPTS: '3',
|
INVENTREE_LOGIN_ATTEMPTS: '3',
|
||||||
INVENTREE_PLUGINS_MANDATORY: 'samplelocate',
|
INVENTREE_PLUGINS_MANDATORY: 'samplelocate',
|
||||||
INVENTREE_CUSTOM_SPLASH: 'img/playwright_custom_splash.png',
|
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/',
|
url: 'http://localhost:8000/api/',
|
||||||
reuseExistingServer: IS_CI,
|
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 />
|
microsoft: <IconBrandAzure />
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getBrandIcon(provider: AuthProvider) {
|
||||||
|
return brandIcons[provider.id] || <IconLogin />;
|
||||||
|
}
|
||||||
|
|
||||||
export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
|
export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -42,7 +46,6 @@ export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
|
|||||||
<Button
|
<Button
|
||||||
leftSection={getBrandIcon(provider)}
|
leftSection={getBrandIcon(provider)}
|
||||||
radius='xl'
|
radius='xl'
|
||||||
component='a'
|
|
||||||
onClick={() => ProviderLogin(provider)}
|
onClick={() => ProviderLogin(provider)}
|
||||||
>
|
>
|
||||||
{provider.name}
|
{provider.name}
|
||||||
@@ -50,6 +53,3 @@ export function SsoButton({ provider }: Readonly<{ provider: AuthProvider }>) {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function getBrandIcon(provider: AuthProvider) {
|
|
||||||
return brandIcons[provider.id] || <IconLogin />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@github/webauthn-json/browser-ponyfill';
|
} from '@github/webauthn-json/browser-ponyfill';
|
||||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||||
import { apiUrl } from '@lib/functions/Api';
|
import { apiUrl } from '@lib/functions/Api';
|
||||||
|
import { getBaseUrl } from '@lib/functions/Navigation';
|
||||||
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
import { type AuthProvider, FlowEnum } from '@lib/types/Auth';
|
||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { notifications, showNotification } from '@mantine/notifications';
|
import { notifications, showNotification } from '@mantine/notifications';
|
||||||
@@ -481,12 +482,24 @@ export const checkLoginState = async (
|
|||||||
await loginSuccess();
|
await loginSuccess();
|
||||||
} else if (!no_redirect) {
|
} else if (!no_redirect) {
|
||||||
setLoginChecked(true);
|
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);
|
setLoginChecked(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleSuccessFullAuth(
|
export function handleSuccessFullAuth(
|
||||||
response: any,
|
response: any,
|
||||||
navigate: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
location?: Location<any>,
|
location?: Location<any>,
|
||||||
@@ -546,7 +559,17 @@ export async function ProviderLogin(
|
|||||||
await ensureCsrf();
|
await ensureCsrf();
|
||||||
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
|
post(generateUrl(apiUrl(ApiEndpoints.auth_provider_redirect)), {
|
||||||
provider: provider.id,
|
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,
|
process: process,
|
||||||
csrfmiddlewaretoken: getCsrfCookie()
|
csrfmiddlewaretoken: getCsrfCookie()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,50 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { useEffect } from 'react';
|
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 { checkLoginState } from '../../functions/auth';
|
||||||
|
import { showLoginNotification } from '../../functions/notifications';
|
||||||
import { Wrapper } from './Layout';
|
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() {
|
export default function Logged_In() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
checkLoginState(navigate, location?.state);
|
||||||
}, [navigate]);
|
}, [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 Register = Loadable(lazy(() => import('./pages/Auth/Register')));
|
||||||
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
||||||
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
|
export const MfaSetup = Loadable(lazy(() => import('./pages/Auth/MFASetup')));
|
||||||
|
export const ProviderSignup = Loadable(
|
||||||
|
lazy(() => import('./pages/Auth/ProviderSignup'))
|
||||||
|
);
|
||||||
export const ChangePassword = Loadable(
|
export const ChangePassword = Loadable(
|
||||||
lazy(() => import('./pages/Auth/ChangePassword'))
|
lazy(() => import('./pages/Auth/ChangePassword'))
|
||||||
);
|
);
|
||||||
@@ -229,6 +232,7 @@ export const routes = (
|
|||||||
<Route path='/register' element={<Register />} />,
|
<Route path='/register' element={<Register />} />,
|
||||||
<Route path='/mfa' element={<Mfa />} />,
|
<Route path='/mfa' element={<Mfa />} />,
|
||||||
<Route path='/mfa-setup' element={<MfaSetup />} />,
|
<Route path='/mfa-setup' element={<MfaSetup />} />,
|
||||||
|
<Route path='/provider-signup' element={<ProviderSignup />} />,
|
||||||
<Route path='/change-password' element={<ChangePassword />} />
|
<Route path='/change-password' element={<ChangePassword />} />
|
||||||
<Route path='/reset-password' element={<Reset />} />
|
<Route path='/reset-password' element={<Reset />} />
|
||||||
<Route path='/set-password' element={<ResetPassword />} />
|
<Route path='/set-password' element={<ResetPassword />} />
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ export const useLocalState = create<LocalStateProps>()(
|
|||||||
host = Object.values(state.hostList)[0].host;
|
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 no host is provided, fallback to using the current URL (default)
|
||||||
if (!host) {
|
if (!host) {
|
||||||
host = window.location.origin;
|
host = window.location.origin;
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
|
|||||||
set({ mfa_context });
|
set({ mfa_context });
|
||||||
},
|
},
|
||||||
sso_enabled: () => {
|
sso_enabled: () => {
|
||||||
|
if (!get_server_setting(get().server?.settings?.sso_enabled)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const data = get().auth_config?.socialaccount.providers;
|
const data = get().auth_config?.socialaccount.providers;
|
||||||
return !(data === undefined || data.length == 0);
|
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 type { UserProps, UserStateProps } from '@lib/types/User';
|
||||||
import { api, setApiDefaults } from '../App';
|
import { api, setApiDefaults } from '../App';
|
||||||
import { clearCsrfCookie } from '../functions/auth';
|
import { clearCsrfCookie } from '../functions/auth';
|
||||||
|
import { useServerApiState } from './ServerApiState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global user information state, using Zustand manager
|
* Global user information state, using Zustand manager
|
||||||
@@ -57,7 +58,12 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
get().setAuthenticated(false);
|
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);
|
get().setAuthenticated(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface ServerAPIProps {
|
|||||||
default_locale: null | string;
|
default_locale: null | string;
|
||||||
django_admin: null | string;
|
django_admin: null | string;
|
||||||
settings: {
|
settings: {
|
||||||
|
sso_enabled: null | boolean;
|
||||||
sso_registration: null | boolean;
|
sso_registration: null | boolean;
|
||||||
registration_enabled: null | boolean;
|
registration_enabled: null | boolean;
|
||||||
password_forgotten_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/user/me/token/') &&
|
||||||
!url.includes('/api/auth/v1/auth/login') &&
|
!url.includes('/api/auth/v1/auth/login') &&
|
||||||
!url.includes('/api/auth/v1/auth/session') &&
|
!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/authenticators/totp') &&
|
||||||
!url.includes('/api/auth/v1/account/password/change') &&
|
!url.includes('/api/auth/v1/account/password/change') &&
|
||||||
!url.includes('/api/barcode/') &&
|
!url.includes('/api/barcode/') &&
|
||||||
|
|||||||
@@ -43,3 +43,22 @@ export const engineeruser: UserType = {
|
|||||||
username: 'engineer',
|
username: 'engineer',
|
||||||
testcred: 'partsonly'
|
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"
|
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042"
|
||||||
integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==
|
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:
|
before-after-hook@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9"
|
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"
|
resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-2.1.0.tgz#5c8d733a0b201c80dff7bb7c0df58c6a255c7c69"
|
||||||
integrity sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==
|
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:
|
is-stream@^2.0.0:
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
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"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
|
||||||
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
|
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:
|
js-sha256@^0.10.1:
|
||||||
version "0.10.1"
|
version "0.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"
|
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"
|
test-exclude "^8.0.0"
|
||||||
yargs "^15.0.2"
|
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:
|
object-assign@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
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"
|
"@rollup/rollup-win32-x64-msvc" "4.62.4"
|
||||||
fsevents "~2.3.2"
|
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:
|
safe-buffer@~5.2.0:
|
||||||
version "5.2.1"
|
version "5.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||||
|
|||||||
Reference in New Issue
Block a user