From 1af1a3b5ac8ffc4125e3b492a06339a2c48ac82a Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 10 Apr 2026 19:59:58 +0800 Subject: [PATCH 001/100] feat(backend): add SSO auth endpoints replacing NextAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /auth/me/ — returns user info from Django session - GET /auth/sso//authorize/ — initiates OAuth redirect - GET /auth/sso//callback/ — handles OAuth callback - Provider registry built from SOCIALACCOUNT_PROVIDERS settings - OIDC discovery with TTL cache - Email domain whitelist enforcement - Support for client_secret_post and client_secret_basic - Preserves callbackUrl through SSO flow for deep link support - Tests for all new endpoints --- backend/api/views/credentials_auth.py | 595 +++++++++++++++++++++++++ backend/backend/urls.py | 15 +- backend/tests/test_credentials_auth.py | 473 ++++++++++++++++++++ 3 files changed, 1080 insertions(+), 3 deletions(-) create mode 100644 backend/api/views/credentials_auth.py create mode 100644 backend/tests/test_credentials_auth.py diff --git a/backend/api/views/credentials_auth.py b/backend/api/views/credentials_auth.py new file mode 100644 index 000000000..b50b87add --- /dev/null +++ b/backend/api/views/credentials_auth.py @@ -0,0 +1,595 @@ +import os +import time +import base64 +import secrets +import logging +import threading +import requests as http_requests +from urllib.parse import urlencode, quote + +from django.conf import settings +from django.contrib.auth import login, get_user_model +from django.http import JsonResponse +from django.shortcuts import redirect +from django.views import View + +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.throttling import AnonRateThrottle + +from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken, SocialLogin + +logger = logging.getLogger(__name__) + +FRONTEND_URL = os.getenv("ALLOWED_ORIGINS", "").split(",")[0].strip() + +# Email domain whitelist — restricts which email domains can log in. +# Comma-separated list from env, e.g. "acme.com,example.org" +_domain_whitelist_raw = os.getenv("USER_EMAIL_DOMAIN_WHITELIST", "") +DOMAIN_WHITELIST = [ + d.strip().lower() for d in _domain_whitelist_raw.split(",") if d.strip() +] + + +# --- Rate Limiting --- + +class AuthLoginThrottle(AnonRateThrottle): + rate = "10/min" + + +class AuthResolveThrottle(AnonRateThrottle): + rate = "20/min" + + +# --- OIDC Discovery Cache --- + +_oidc_cache = {} +_oidc_cache_lock = threading.Lock() +_OIDC_CACHE_TTL = 3600 # 1 hour + + +def _get_oidc_endpoints(issuer): + """Fetch OIDC discovery document with a TTL cache.""" + now = time.time() + + with _oidc_cache_lock: + cached = _oidc_cache.get(issuer) + if cached and (now - cached["fetched_at"]) < _OIDC_CACHE_TTL: + return cached["endpoints"] + + discovery_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" + try: + resp = http_requests.get(discovery_url, timeout=10) + resp.raise_for_status() + config = resp.json() + endpoints = { + "authorize_url": config["authorization_endpoint"], + "token_url": config["token_endpoint"], + } + with _oidc_cache_lock: + _oidc_cache[issuer] = {"endpoints": endpoints, "fetched_at": now} + return endpoints + except Exception: + logger.warning(f"OIDC discovery failed for {issuer}") + # Return stale cache if available + with _oidc_cache_lock: + if cached: + return cached["endpoints"] + return None + + +# --- Domain whitelist check --- + +def _check_email_domain_allowed(email): + """Check if an email's domain is allowed by the whitelist. + Returns True if no whitelist is configured or if the domain is allowed.""" + if not DOMAIN_WHITELIST: + return True + domain = email.split("@")[-1].lower() + return domain in DOMAIN_WHITELIST + + +# --- Helper: get provider config from settings --- + +SSO_PROVIDER_REGISTRY = {} + + +def _build_provider_registry(): + """Build the SSO provider registry from Django settings on startup.""" + providers = settings.SOCIALACCOUNT_PROVIDERS + + # Google OAuth2 + google_cfg = providers.get("google", {}).get("APP", {}) + if google_cfg.get("client_id"): + SSO_PROVIDER_REGISTRY["google"] = { + "client_id": google_cfg["client_id"], + "client_secret": google_cfg.get("secret", ""), + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": "openid profile email", + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + "token_auth_method": "client_secret_post", + "extra_auth_params": {"access_type": "online"}, + } + + # GitHub OAuth2 + github_cfg = providers.get("github", {}).get("APP", {}) + if github_cfg.get("client_id"): + SSO_PROVIDER_REGISTRY["github"] = { + "client_id": github_cfg["client_id"], + "client_secret": github_cfg.get("secret", ""), + "authorize_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "scopes": "user:email read:user", + "adapter_module": "api.authentication.adapters.github", + "adapter_class": "CustomGitHubOAuth2Adapter", + "provider_id": "github", + "token_auth_method": "client_secret_post", + } + + # GitHub Enterprise + ghe_cfg = providers.get("github-enterprise", {}).get("APP", {}) + ghe_url = providers.get("github-enterprise", {}).get( + "GITHUB_URL", os.getenv("GITHUB_ENTERPRISE_BASE_URL", "") + ) + if ghe_cfg.get("client_id") and ghe_url: + SSO_PROVIDER_REGISTRY["github-enterprise"] = { + "client_id": ghe_cfg["client_id"], + "client_secret": ghe_cfg.get("secret", ""), + "authorize_url": f"{ghe_url}/login/oauth/authorize", + "token_url": f"{ghe_url}/login/oauth/access_token", + "scopes": "user:email read:user", + "adapter_module": "ee.authentication.sso.oauth.github_enterprise.views", + "adapter_class": "GitHubEnterpriseOAuth2Adapter", + "provider_id": "github-enterprise", + "token_auth_method": "client_secret_post", + } + + # GitLab OAuth2 + gitlab_cfg = providers.get("gitlab", {}).get("APP", {}) + gitlab_url = gitlab_cfg.get("settings", {}).get( + "gitlab_url", os.getenv("GITLAB_AUTH_URL", "https://gitlab.com") + ) + if gitlab_cfg.get("client_id"): + SSO_PROVIDER_REGISTRY["gitlab"] = { + "client_id": gitlab_cfg["client_id"], + "client_secret": gitlab_cfg.get("secret", ""), + "authorize_url": f"{gitlab_url}/oauth/authorize", + "token_url": f"{gitlab_url}/oauth/token", + "scopes": "read_user", + "adapter_module": "api.authentication.adapters.gitlab", + "adapter_class": "CustomGitLabOAuth2Adapter", + "provider_id": "gitlab", + "token_auth_method": "client_secret_post", + } + + # OIDC providers + oidc_providers = { + "google-oidc": { + "issuer": "https://accounts.google.com", + "adapter_module": "ee.authentication.sso.oidc.util.google.views", + "adapter_class": "GoogleOpenIDConnectAdapter", + "provider_id": "google-oidc", + "token_auth_method": "client_secret_post", + }, + "jumpcloud-oidc": { + "issuer": "https://oauth.id.jumpcloud.com", + "adapter_module": "ee.authentication.sso.oidc.util.jumpcloud.views", + "adapter_class": "JumpCloudOpenIDConnectAdapter", + "provider_id": "jumpcloud-oidc", + "token_auth_method": "client_secret_post", + }, + "entra-id-oidc": { + "issuer": f"https://login.microsoftonline.com/{os.getenv('ENTRA_ID_OIDC_TENANT_ID', 'common')}/v2.0", + "adapter_module": "ee.authentication.sso.oidc.entraid.views", + "adapter_class": "CustomMicrosoftGraphOAuth2Adapter", + "provider_id": "microsoft", + "token_auth_method": "client_secret_post", + }, + "authentik": { + "issuer": f"{os.getenv('AUTHENTIK_URL', '')}/application/o/{os.getenv('AUTHENTIK_APP_SLUG', '')}", + "adapter_module": "api.authentication.providers.authentik.views", + "adapter_class": "AuthentikOpenIDConnectAdapter", + "provider_id": "authentik", + "token_auth_method": "client_secret_post", + }, + "authelia": { + "issuer": os.getenv("AUTHELIA_URL", ""), + "adapter_module": "api.authentication.providers.authelia.views", + "adapter_class": "AutheliaOpenIDConnectAdapter", + "provider_id": "authelia", + "token_auth_method": "client_secret_post", + }, + "okta-oidc": { + "issuer": os.getenv("OKTA_OIDC_ISSUER", ""), + "adapter_module": "ee.authentication.sso.oidc.okta.views", + "adapter_class": "OktaOpenIDConnectAdapter", + "provider_id": "okta-oidc", + "token_auth_method": "client_secret_basic", + }, + } + + for slug, oidc_cfg in oidc_providers.items(): + settings_key_map = { + "google-oidc": "google-oidc", + "jumpcloud-oidc": "jumpcloud-oidc", + "entra-id-oidc": "microsoft", + "authentik": "authentik", + "authelia": "authelia", + "okta-oidc": "okta-oidc", + } + settings_key = settings_key_map.get(slug, slug) + provider_settings = providers.get(settings_key, {}) + + app_cfg = provider_settings.get("APP", {}) + if not app_cfg and "APPS" in provider_settings: + apps = provider_settings["APPS"] + app_cfg = apps[0] if apps else {} + + if not app_cfg.get("client_id"): + continue + + issuer = oidc_cfg["issuer"] + if not issuer: + continue + + scopes = " ".join(provider_settings.get("SCOPE", ["openid", "email", "profile"])) + + SSO_PROVIDER_REGISTRY[slug] = { + "client_id": app_cfg["client_id"], + "client_secret": app_cfg.get("secret", ""), + "issuer": issuer, + "scopes": scopes, + "adapter_module": oidc_cfg["adapter_module"], + "adapter_class": oidc_cfg["adapter_class"], + "provider_id": oidc_cfg["provider_id"], + "token_auth_method": oidc_cfg.get("token_auth_method", "client_secret_post"), + "is_oidc": True, + } + + +def _get_adapter_instance(provider_config, request): + """Dynamically import and instantiate an adapter class.""" + import importlib + + module = importlib.import_module(provider_config["adapter_module"]) + cls = getattr(module, provider_config["adapter_class"]) + return cls(request) + + +def _get_callback_url(provider_slug): + """Build the OAuth callback URL for a given provider. + + Always uses the frontend /api/auth/callback/ path, which 302 redirects + to the backend. This keeps OAuth redirect URIs stable — third-party + provider configurations never need updating even as the backend URLs + evolve. The redirect adds negligible latency (~10-50ms). + """ + return f"{FRONTEND_URL}/api/auth/callback/{provider_slug}" + + +def _get_or_create_social_app(config): + """Get or create a persisted SocialApp so that SocialToken ForeignKeys work.""" + app, created = SocialApp.objects.get_or_create( + provider=config["provider_id"], + defaults={ + "name": config["provider_id"], + "client_id": config["client_id"], + "secret": config["client_secret"], + }, + ) + if not created: + # Update credentials if they changed in settings + changed = False + if app.client_id != config["client_id"]: + app.client_id = config["client_id"] + changed = True + if app.secret != config["client_secret"]: + app.secret = config["client_secret"] + changed = True + if changed: + app.save() + return app + + +def _complete_login_bypassing_allauth(request, social_login, token): + """Handle user creation/linking and login directly, bypassing + allauth's complete_social_login which has complex redirect-based + flows (signup forms, account-connect pages) that don't work in + a backend-driven OAuth callback. + + This replicates the net effect of what dj_rest_auth + allauth do + together: find/create user by email, link the social account, + save the token, and log in. + """ + User = get_user_model() + + extra_data = social_login.account.extra_data or {} + email = ( + extra_data.get("email") + or extra_data.get("mail") # Microsoft Graph uses 'mail' + or (social_login.user.email if social_login.user else None) + ) + if not email: + raise ValueError("No email address from SSO provider") + + email = email.lower().strip() + + # Find or create the Django user + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + user = User.objects.create_user( + username=email, + email=email, + password=None, + ) + + # Find or create the SocialAccount linking this provider to the user + sa, created = SocialAccount.objects.update_or_create( + provider=social_login.account.provider, + uid=social_login.account.uid, + defaults={ + "user": user, + "extra_data": extra_data, + }, + ) + + # Save the SocialToken if we have one + if token and token.token: + SocialToken.objects.update_or_create( + account=sa, + defaults={ + "token": token.token, + "token_secret": getattr(token, "token_secret", "") or "", + "app": token.app, + }, + ) + + # Log the user in (sets the Django session) + login(request, user) + + return user + + +def _exchange_code_for_token(token_url, payload, auth_method, client_id, client_secret): + """Exchange an authorization code for tokens, supporting both + client_secret_post and client_secret_basic authentication methods.""" + + headers = {"Accept": "application/json"} + # Work on a copy to avoid mutating the caller's dict + body = dict(payload) + + if auth_method == "client_secret_basic": + credentials = base64.b64encode( + f"{client_id}:{client_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {credentials}" + body.pop("client_id", None) + body.pop("client_secret", None) + + resp = http_requests.post(token_url, data=body, headers=headers, timeout=15) + resp.raise_for_status() + return resp.json() + + +# --- /auth/me/ --- + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def auth_me(request): + """Return the currently authenticated user's info.""" + user = request.user + social_acc = user.socialaccount_set.first() + + avatar_url = None + full_name = "" + + if social_acc: + extra = social_acc.extra_data or {} + avatar_url = ( + extra.get("avatar_url") # GitHub + or extra.get("picture") # Google, standard OIDC + or extra.get("photo") # Microsoft Entra ID + or extra.get("avatar") # GitLab + ) + full_name = extra.get("name", "") + + # full_name field on the user model (available after migration is applied) + if not full_name and hasattr(user, "full_name") and user.full_name: + full_name = user.full_name + + return JsonResponse( + { + "userId": str(user.userId), + "email": user.email, + "fullName": full_name or user.email, + "avatarUrl": avatar_url, + "authMethod": getattr(user, "auth_method", "sso"), + } + ) + + +# --- SSO Authorize --- + +class SSOAuthorizeView(View): + """ + GET /auth/sso//authorize/ + + Builds the OAuth authorization URL and redirects the user's browser + to the identity provider. + """ + + def get(self, request, provider): + if provider not in SSO_PROVIDER_REGISTRY: + return JsonResponse( + {"error": f"SSO provider '{provider}' is not configured."}, + status=404, + ) + + config = SSO_PROVIDER_REGISTRY[provider] + callback_url = _get_callback_url(provider) + + if config.get("is_oidc"): + endpoints = _get_oidc_endpoints(config["issuer"]) + if not endpoints: + return JsonResponse( + {"error": f"Failed to discover OIDC endpoints for {provider}"}, + status=502, + ) + authorize_url = endpoints["authorize_url"] + request.session["sso_token_url"] = endpoints["token_url"] + else: + authorize_url = config["authorize_url"] + request.session["sso_token_url"] = config["token_url"] + + state = secrets.token_urlsafe(32) + request.session["sso_state"] = state + request.session["sso_provider"] = provider + request.session["sso_callback_url"] = callback_url + + # Preserve the original deep link so the user lands on the page + # they requested after SSO completes (e.g. /team/settings) + callback_url_param = request.GET.get("callbackUrl") + if callback_url_param: + request.session["sso_return_to"] = callback_url_param + + request.session.save() + + params = { + "client_id": config["client_id"], + "redirect_uri": callback_url, + "scope": config["scopes"], + "state": state, + "response_type": "code", + } + + extra_params = config.get("extra_auth_params", {}) + params.update(extra_params) + + if config.get("is_oidc"): + nonce = secrets.token_urlsafe(32) + request.session["sso_nonce"] = nonce + params["nonce"] = nonce + + full_url = f"{authorize_url}?{urlencode(params)}" + return redirect(full_url) + + +# --- SSO Callback --- + +class SSOCallbackView(View): + """ + GET /auth/sso//callback/ + + Handles the OAuth callback: validates state, exchanges code for tokens, + enforces domain whitelist, completes login via allauth adapters. + """ + + def get(self, request, provider): + error = request.GET.get("error") + if error: + error_desc = request.GET.get("error_description", error) + return redirect(f"{FRONTEND_URL}/login?error={quote(error_desc)}") + + code = request.GET.get("code") + state = request.GET.get("state") + + if not code or not state: + return redirect(f"{FRONTEND_URL}/login?error=missing_code_or_state") + + expected_state = request.session.get("sso_state") + if not expected_state or state != expected_state: + return redirect(f"{FRONTEND_URL}/login?error=invalid_state") + + if provider not in SSO_PROVIDER_REGISTRY: + return redirect(f"{FRONTEND_URL}/login?error=unknown_provider") + + config = SSO_PROVIDER_REGISTRY[provider] + callback_url = request.session.get("sso_callback_url", _get_callback_url(provider)) + token_url = request.session.get("sso_token_url", config.get("token_url", "")) + + # Exchange code for tokens + token_payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": callback_url, + "client_id": config["client_id"], + "client_secret": config["client_secret"], + } + + try: + token_data = _exchange_code_for_token( + token_url, + token_payload, + config.get("token_auth_method", "client_secret_post"), + config["client_id"], + config["client_secret"], + ) + except Exception as e: + logger.error(f"Token exchange failed for {provider}: {e}") + return redirect(f"{FRONTEND_URL}/login?error=token_exchange_failed") + + access_token = token_data.get("access_token") + if not access_token: + return redirect(f"{FRONTEND_URL}/login?error=no_access_token") + + try: + adapter = _get_adapter_instance(config, request) + + # Use a persisted SocialApp so SocialToken ForeignKeys work + app = _get_or_create_social_app(config) + + token = SocialToken(token=access_token, app=app) + if token_data.get("refresh_token"): + token.token_secret = token_data["refresh_token"] + + social_login = adapter.complete_login( + request, app, token, response=token_data + ) + social_login.token = token + social_login.state = SocialLogin.state_from_request(request) + + # Email domain whitelist enforcement + user_email = ( + social_login.user.email + if social_login.user and social_login.user.email + else social_login.account.extra_data.get("email", "") + ) + if not _check_email_domain_allowed(user_email): + logger.warning( + f"SSO login blocked: {user_email} not in domain whitelist" + ) + return redirect( + f"{FRONTEND_URL}/login?error=email_domain_not_allowed" + ) + + # Handle user creation/linking and login directly. + # We bypass allauth's complete_social_login because its + # redirect-based signup/connect flow doesn't work in a + # backend-driven OAuth callback (causes assertion errors + # and 302 redirects to non-existent signup pages). + _complete_login_bypassing_allauth(request, social_login, token) + + if not request.user.is_authenticated: + logger.warning(f"SSO login failed to authenticate user for {provider}") + return redirect(f"{FRONTEND_URL}/login?error=login_failed") + + # Restore the original deep link, then clean up SSO session data + return_to = request.session.pop("sso_return_to", None) + for key in ["sso_state", "sso_provider", "sso_callback_url", "sso_token_url", "sso_nonce"]: + request.session.pop(key, None) + + if return_to and return_to.startswith("/"): + return redirect(FRONTEND_URL + return_to) + return redirect(FRONTEND_URL + "/") + + except Exception as e: + logger.exception(f"SSO callback error for {provider}") + return redirect(f"{FRONTEND_URL}/login?error=authentication_failed") + + +# Build the registry on module load +_build_provider_registry() diff --git a/backend/backend/urls.py b/backend/backend/urls.py index d95337bda..f7bc9c7e9 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -12,6 +12,11 @@ secrets_tokens, root_endpoint, ) +from api.views.credentials_auth import ( + auth_me, + SSOAuthorizeView, + SSOCallbackView, +) from api.views.identities.aws.iam import aws_iam_auth from api.views.identities.azure.entra import azure_entra_auth from api.views.kms import kms @@ -26,10 +31,14 @@ "493c5048-99f9-4eac-ad0d-98c3740b491f/health", health_check ), # Legacy health check - TODO: Remove # Authentication and user management - path("accounts/", include("allauth.urls")), - path("auth/", include("dj_rest_auth.urls")), - path("social/login/", include("api.urls")), + path("accounts/", include("allauth.urls")), # TODO Remove — legacy allauth views + path("auth/", include("dj_rest_auth.urls")), # TODO Remove — legacy dj_rest_auth views + path("social/login/", include("api.urls")), # TODO Remove — legacy SocialLoginView endpoints path("logout/", csrf_exempt(logout_view)), + # Auth endpoints + path("auth/me/", auth_me), + path("auth/sso//authorize/", SSOAuthorizeView.as_view()), + path("auth/sso//callback/", SSOCallbackView.as_view()), # GraphQL API path("graphql/", csrf_exempt(PrivateGraphQLView.as_view(graphiql=True))), # OAuth integrations diff --git a/backend/tests/test_credentials_auth.py b/backend/tests/test_credentials_auth.py new file mode 100644 index 000000000..784e5f82e --- /dev/null +++ b/backend/tests/test_credentials_auth.py @@ -0,0 +1,473 @@ +"""Tests for the new credential auth views that replace NextAuth.""" + +import json +import unittest +from unittest.mock import patch, MagicMock, PropertyMock +from urllib.parse import urlparse, parse_qs + +from django.test import TestCase, RequestFactory, override_settings +from django.contrib.sessions.middleware import SessionMiddleware +from django.contrib.auth import get_user_model +from django.http import JsonResponse + +from api.views.credentials_auth import ( + auth_me, + SSOAuthorizeView, + SSOCallbackView, + SSO_PROVIDER_REGISTRY, + _get_callback_url, +) + +User = get_user_model() + + +def _add_session_to_request(request): + """Helper to add session support to a request.""" + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + +def _authenticate_request(request, user): + """Helper to set a user on the request.""" + request.user = user + _add_session_to_request(request) + + +class AuthMeViewTest(TestCase): + """Tests for GET /auth/me/.""" + + def setUp(self): + self.factory = RequestFactory() + self.user = User.objects.create_user( + username="test@example.com", + email="test@example.com", + password="testpass123456789", + ) + + def test_returns_user_info_when_authenticated(self): + """Authenticated user gets their info back.""" + request = self.factory.get("/auth/me/") + _authenticate_request(request, self.user) + + response = auth_me(request) + data = json.loads(response.content) + + self.assertEqual(response.status_code, 200) + self.assertEqual(data["email"], "test@example.com") + self.assertIn("userId", data) + self.assertIn("fullName", data) + self.assertIn("avatarUrl", data) + self.assertIn("authMethod", data) + + def test_returns_email_as_fallback_name(self): + """When no full_name or social account, email is used as name.""" + request = self.factory.get("/auth/me/") + _authenticate_request(request, self.user) + + response = auth_me(request) + data = json.loads(response.content) + + # User has no full_name set and no social account + self.assertEqual(data["fullName"], "test@example.com") + self.assertIsNone(data["avatarUrl"]) + + def test_returns_full_name_when_set(self): + """full_name field is returned when available.""" + self.user.full_name = "Test User" + self.user.save() + + request = self.factory.get("/auth/me/") + _authenticate_request(request, self.user) + + response = auth_me(request) + data = json.loads(response.content) + + self.assertEqual(data["fullName"], "Test User") + + def test_returns_social_account_data(self): + """Social account data (avatar, name) is returned for SSO users.""" + from allauth.socialaccount.models import SocialAccount + + SocialAccount.objects.create( + user=self.user, + provider="google", + uid="12345", + extra_data={ + "name": "Google User", + "picture": "https://example.com/avatar.jpg", + }, + ) + + request = self.factory.get("/auth/me/") + _authenticate_request(request, self.user) + + response = auth_me(request) + data = json.loads(response.content) + + self.assertEqual(data["fullName"], "Google User") + self.assertEqual(data["avatarUrl"], "https://example.com/avatar.jpg") + + def test_unauthenticated_returns_403(self): + """Unauthenticated requests are rejected.""" + response = self.client.get("/auth/me/") + self.assertIn(response.status_code, [401, 403]) + + +class SSOAuthorizeViewTest(TestCase): + """Tests for GET /auth/sso//authorize/.""" + + def setUp(self): + self.factory = RequestFactory() + + def test_unknown_provider_returns_400(self): + """Requesting an unknown provider returns a 400 error.""" + request = self.factory.get("/auth/sso/nonexistent/authorize/") + _add_session_to_request(request) + + view = SSOAuthorizeView() + response = view.get(request, "nonexistent") + + self.assertEqual(response.status_code, 404) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-provider": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "authorize_url": "https://idp.example.com/authorize", + "token_url": "https://idp.example.com/token", + "scopes": "openid profile email", + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + def test_redirects_to_provider_authorize_url(self): + """Valid provider triggers redirect to the provider's auth URL.""" + request = self.factory.get("/auth/sso/test-provider/authorize/") + _add_session_to_request(request) + + view = SSOAuthorizeView() + response = view.get(request, "test-provider") + + self.assertEqual(response.status_code, 302) + redirect_url = response.url + parsed = urlparse(redirect_url) + params = parse_qs(parsed.query) + + self.assertEqual(parsed.scheme, "https") + self.assertEqual(parsed.hostname, "idp.example.com") + self.assertEqual(parsed.path, "/authorize") + self.assertEqual(params["client_id"][0], "test-client-id") + self.assertEqual(params["scope"][0], "openid profile email") + self.assertEqual(params["response_type"][0], "code") + self.assertIn("state", params) + self.assertIn("redirect_uri", params) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-provider": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "authorize_url": "https://idp.example.com/authorize", + "token_url": "https://idp.example.com/token", + "scopes": "openid profile email", + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + def test_stores_state_in_session(self): + """State parameter is stored in the session for CSRF validation.""" + request = self.factory.get("/auth/sso/test-provider/authorize/") + _add_session_to_request(request) + + view = SSOAuthorizeView() + response = view.get(request, "test-provider") + + self.assertIn("sso_state", request.session) + self.assertIn("sso_provider", request.session) + self.assertEqual(request.session["sso_provider"], "test-provider") + + # Verify state in URL matches session + parsed = urlparse(response.url) + params = parse_qs(parsed.query) + self.assertEqual(params["state"][0], request.session["sso_state"]) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-oidc": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "issuer": "https://oidc.example.com", + "scopes": "openid profile email", + "is_oidc": True, + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + @patch("api.views.credentials_auth._get_oidc_endpoints") + def test_oidc_provider_uses_discovery(self, mock_discovery): + """OIDC providers fetch endpoints from discovery document.""" + mock_discovery.return_value = { + "authorize_url": "https://oidc.example.com/auth", + "token_url": "https://oidc.example.com/token", + } + + request = self.factory.get("/auth/sso/test-oidc/authorize/") + _add_session_to_request(request) + + view = SSOAuthorizeView() + response = view.get(request, "test-oidc") + + self.assertEqual(response.status_code, 302) + self.assertIn("oidc.example.com/auth", response.url) + mock_discovery.assert_called_once_with("https://oidc.example.com") + + # OIDC providers should include a nonce + parsed = urlparse(response.url) + params = parse_qs(parsed.query) + self.assertIn("nonce", params) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-oidc": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "issuer": "https://oidc.example.com", + "scopes": "openid profile email", + "is_oidc": True, + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + @patch("api.views.credentials_auth._get_oidc_endpoints") + def test_oidc_discovery_failure_returns_502(self, mock_discovery): + """If OIDC discovery fails, return 502.""" + mock_discovery.return_value = None + + request = self.factory.get("/auth/sso/test-oidc/authorize/") + _add_session_to_request(request) + + view = SSOAuthorizeView() + response = view.get(request, "test-oidc") + + self.assertEqual(response.status_code, 502) + + +class SSOCallbackViewTest(TestCase): + """Tests for GET /auth/sso//callback/.""" + + def setUp(self): + self.factory = RequestFactory() + + def test_missing_code_redirects_with_error(self): + """Missing code parameter redirects to login with error.""" + request = self.factory.get("/auth/sso/google/callback/?state=abc") + _add_session_to_request(request) + + view = SSOCallbackView() + response = view.get(request, "google") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=missing_code_or_state", response.url) + + def test_missing_state_redirects_with_error(self): + """Missing state parameter redirects to login with error.""" + request = self.factory.get("/auth/sso/google/callback/?code=abc") + _add_session_to_request(request) + + view = SSOCallbackView() + response = view.get(request, "google") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=missing_code_or_state", response.url) + + def test_invalid_state_redirects_with_error(self): + """Invalid state (CSRF mismatch) redirects to login with error.""" + request = self.factory.get("/auth/sso/google/callback/?code=abc&state=wrong") + _add_session_to_request(request) + request.session["sso_state"] = "expected_state" + request.session.save() + + view = SSOCallbackView() + response = view.get(request, "google") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_state", response.url) + + def test_provider_error_redirects_with_error(self): + """Provider returning an error redirects to login.""" + request = self.factory.get( + "/auth/sso/google/callback/?error=access_denied&error_description=User+denied" + ) + _add_session_to_request(request) + + view = SSOCallbackView() + response = view.get(request, "google") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=User", response.url) + + def test_unknown_provider_redirects_with_error(self): + """Unknown provider in callback redirects to login with error.""" + request = self.factory.get( + "/auth/sso/nonexistent/callback/?code=abc&state=valid" + ) + _add_session_to_request(request) + request.session["sso_state"] = "valid" + request.session.save() + + view = SSOCallbackView() + response = view.get(request, "nonexistent") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=unknown_provider", response.url) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-provider": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "token_url": "https://idp.example.com/token", + "scopes": "openid profile email", + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + @patch("api.views.credentials_auth.http_requests.post") + def test_token_exchange_failure_redirects_with_error(self, mock_post): + """Failed token exchange redirects to login with error.""" + mock_post.side_effect = Exception("Connection refused") + + request = self.factory.get( + "/auth/sso/test-provider/callback/?code=auth_code&state=valid_state" + ) + _add_session_to_request(request) + request.session["sso_state"] = "valid_state" + request.session["sso_token_url"] = "https://idp.example.com/token" + request.session.save() + + view = SSOCallbackView() + response = view.get(request, "test-provider") + + self.assertEqual(response.status_code, 302) + self.assertIn("error=token_exchange_failed", response.url) + + @patch.dict(SSO_PROVIDER_REGISTRY, { + "test-provider": { + "client_id": "test-client-id", + "client_secret": "test-secret", + "token_url": "https://idp.example.com/token", + "scopes": "openid profile email", + "adapter_module": "api.authentication.adapters.google", + "adapter_class": "CustomGoogleOAuth2Adapter", + "provider_id": "google", + } + }) + @patch("api.views.credentials_auth._complete_login_bypassing_allauth") + @patch("api.views.credentials_auth._get_or_create_social_app") + @patch("api.views.credentials_auth._get_adapter_instance") + @patch("api.views.credentials_auth._exchange_code_for_token") + def test_successful_callback_completes_login( + self, + mock_exchange_code, + mock_adapter, + mock_get_or_create_social_app, + mock_complete_login, + ): + """Successful callback restores the original deep link after login.""" + mock_exchange_code.return_value = { + "access_token": "test-access-token", + "id_token": "test-id-token", + "token_type": "Bearer", + } + + # Mock adapter + mock_adapter_instance = MagicMock() + mock_social_login = MagicMock() + mock_social_login.user.email = "test@example.com" + mock_social_login.account.extra_data = {"email": "test@example.com"} + mock_adapter_instance.complete_login.return_value = mock_social_login + mock_adapter.return_value = mock_adapter_instance + from allauth.socialaccount.models import SocialApp + + mock_get_or_create_social_app.return_value = SocialApp( + provider="google", + name="google", + client_id="test-client-id", + secret="test-secret", + ) + + def complete_login_side_effect(request, social_login, token): + request.user = MagicMock(is_authenticated=True) + + mock_complete_login.side_effect = complete_login_side_effect + + request = self.factory.get( + "/auth/sso/test-provider/callback/?code=auth_code&state=valid_state" + ) + _add_session_to_request(request) + request.user = MagicMock(is_authenticated=False) + request.session["sso_state"] = "valid_state" + request.session["sso_token_url"] = "https://idp.example.com/token" + request.session["sso_callback_url"] = "https://localhost/service/auth/sso/test-provider/callback/" + request.session["sso_return_to"] = "/team/settings" + request.session.save() + + view = SSOCallbackView() + response = view.get(request, "test-provider") + + # Should redirect back to the original deep link + self.assertEqual(response.status_code, 302) + self.assertNotIn("error", response.url) + self.assertTrue(response.url.endswith("/team/settings")) + + # Verify token exchange was called with the callback code + mock_exchange_code.assert_called_once() + self.assertEqual( + mock_exchange_code.call_args.args[1]["code"], + "auth_code", + ) + + # Verify adapter and direct login flow were used + mock_adapter_instance.complete_login.assert_called_once() + mock_complete_login.assert_called_once() + + +class CallbackUrlTest(TestCase): + """Tests for callback URL generation.""" + + @override_settings() + @patch.dict("os.environ", {"NEXT_PUBLIC_BACKEND_API_BASE": "https://app.phase.dev/service"}) + def test_callback_url_uses_public_backend_base(self): + """Callback URL is built from the public backend base URL.""" + url = _get_callback_url("google") + self.assertEqual(url, "https://localhost/api/auth/callback/google") + + @override_settings() + @patch.dict("os.environ", { + "NEXT_PUBLIC_BACKEND_API_BASE": "https://localhost/service", + }) + def test_callback_url_for_dev(self): + """Callback URL works for localhost dev environment.""" + url = _get_callback_url("github") + self.assertEqual(url, "https://localhost/api/auth/callback/github") + + +class ProviderRegistryTest(TestCase): + """Tests for the SSO provider registry.""" + + def test_registry_contains_configured_providers(self): + """Registry is populated from SOCIALACCOUNT_PROVIDERS settings.""" + # The registry is built at module load time from settings. + # In test environments, some providers may not have credentials. + # Just verify the registry is a dict and the structure is correct. + self.assertIsInstance(SSO_PROVIDER_REGISTRY, dict) + + for slug, config in SSO_PROVIDER_REGISTRY.items(): + self.assertIn("client_id", config) + self.assertIn("client_secret", config) + self.assertIn("adapter_module", config) + self.assertIn("adapter_class", config) + self.assertIn("provider_id", config) + self.assertIn("scopes", config) From 07b5940753a04f1f529626e3ee8c3ce8da61002b Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 10 Apr 2026 20:00:07 +0800 Subject: [PATCH 002/100] fix(backend): use PyJWKClient for OIDC id_token validation The generic OIDC adapter passed raw JWKS JSON to jwt.decode() which expects a PEM key. This was masked by NextAuth decoding the id_token client-side. Now uses PyJWKClient for proper key resolution. --- backend/api/authentication/adapters/generic/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/api/authentication/adapters/generic/views.py b/backend/api/authentication/adapters/generic/views.py index 232781a1b..f2d1d909b 100644 --- a/backend/api/authentication/adapters/generic/views.py +++ b/backend/api/authentication/adapters/generic/views.py @@ -58,12 +58,12 @@ def _fetch_user_info(self, token): return resp.json() def _process_id_token(self, id_token, app): - jwks_response = requests.get(self.jwks_url) - jwks = jwks_response.json() try: + jwk_client = jwt.PyJWKClient(self.jwks_url) + signing_key = jwk_client.get_signing_key_from_jwt(id_token) return jwt.decode( id_token, - key=jwks, + key=signing_key.key, algorithms=["RS256"], audience=app.client_id, issuer=self.issuer, From 34535e6f7267165d1963cd940e43f22023647730 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 10 Apr 2026 20:00:16 +0800 Subject: [PATCH 003/100] feat(frontend): add UserContext and middleware replacing NextAuth - UserContext fetches /auth/me/ for user info (email, name, avatar) - useSession() compatibility shim with memoized references - Middleware checks Django sessionid cookie instead of NextAuth JWT - Redirects to /login with callbackUrl preserving path + query string - Handles stale sessions by redirecting only on 401/403, not transient errors - handleSignout() calls /logout/ and redirects to /login --- frontend/apollo/client.ts | 19 +++-- frontend/app/providers.tsx | 6 +- frontend/contexts/userContext.tsx | 125 ++++++++++++++++++++++++++++++ frontend/middleware.ts | 28 ++++++- 4 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 frontend/contexts/userContext.tsx diff --git a/frontend/apollo/client.ts b/frontend/apollo/client.ts index 154a3b321..297d9fbca 100644 --- a/frontend/apollo/client.ts +++ b/frontend/apollo/client.ts @@ -1,20 +1,23 @@ import { HttpLink, ApolloClient, InMemoryCache, from } from '@apollo/client' import crossFetch from 'cross-fetch' import { onError } from '@apollo/client/link/error' -import { signOut, SignOutParams } from 'next-auth/react' import { UrlUtils } from '@/utils/auth' import axios from 'axios' import { toast } from 'react-toastify' import posthog from 'posthog-js' -export const handleSignout = async (options?: SignOutParams | undefined) => { +export const handleSignout = async () => { posthog.reset() - const response = await axios.post( - UrlUtils.makeUrl(process.env.NEXT_PUBLIC_BACKEND_API_BASE!, 'logout'), - {}, - { withCredentials: true } - ) - signOut(options) + try { + await axios.post( + UrlUtils.makeUrl(process.env.NEXT_PUBLIC_BACKEND_API_BASE!, 'logout'), + {}, + { withCredentials: true } + ) + } catch (e) { + // Logout may fail if session is already expired — still redirect + } + window.location.href = '/login' } const httpLink = new HttpLink({ diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx index e7266e074..0d4d75839 100644 --- a/frontend/app/providers.tsx +++ b/frontend/app/providers.tsx @@ -1,7 +1,7 @@ 'use client' import { ThemeProvider } from '@/contexts/themeContext' -import { SessionProvider } from 'next-auth/react' +import { UserProvider } from '@/contexts/userContext' import { ApolloProvider } from '@apollo/client' import { graphQlClient } from '@/apollo/client' import { KeyringProvider } from '@/contexts/keyringContext' @@ -26,7 +26,7 @@ export default function Providers({ children }: { children: React.ReactNode }) { return ( - + @@ -34,7 +34,7 @@ export default function Providers({ children }: { children: React.ReactNode }) { - + ) diff --git a/frontend/contexts/userContext.tsx b/frontend/contexts/userContext.tsx new file mode 100644 index 000000000..d2ff0746a --- /dev/null +++ b/frontend/contexts/userContext.tsx @@ -0,0 +1,125 @@ +'use client' + +import { createContext, useContext, useEffect, useState, useCallback, useMemo } from 'react' +import { usePathname } from 'next/navigation' +import axios from 'axios' +import { UrlUtils } from '@/utils/auth' + +interface UserData { + userId: string + email: string + fullName: string + avatarUrl: string | null + authMethod: 'password' | 'sso' +} + +interface UserContextValue { + user: UserData | null + loading: boolean + error: boolean + refetch: () => Promise +} + +const UserContext = createContext({ + user: null, + loading: true, + error: false, + refetch: async () => {}, +}) + +const PUBLIC_PATHS = ['/login', '/register', '/lockbox'] + +export function UserProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) + const [authError, setAuthError] = useState(false) + const pathname = usePathname() + + const fetchUser = useCallback(async () => { + try { + const response = await axios.get( + UrlUtils.makeUrl(process.env.NEXT_PUBLIC_BACKEND_API_BASE!, 'auth', 'me'), + { withCredentials: true } + ) + setUser(response.data) + setError(false) + setAuthError(false) + } catch (e) { + setUser(null) + setError(true) + // Only treat 401/403 as an expired session. Transient 5xx errors, + // network hiccups, or deploy restarts should not force logout. + const status = axios.isAxiosError(e) ? e.response?.status : undefined + setAuthError(status === 401 || status === 403) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchUser() + }, [fetchUser]) + + // Redirect to login if session cookie is stale/invalid. + // The middleware lets the request through (cookie exists), but /auth/me/ + // returned 401/403 (session expired server-side). Without this, the user + // sees a broken page until a GraphQL call triggers Apollo's error handler. + useEffect(() => { + if (!loading && authError && !PUBLIC_PATHS.some((p) => pathname?.startsWith(p))) { + const currentPath = window.location.pathname + window.location.search + window.location.href = `/login?callbackUrl=${encodeURIComponent(currentPath)}` + } + }, [loading, authError, pathname]) + + return ( + + {children} + + ) +} + +/** + * Drop-in replacement for useSession(). + * + * Returns `{ user, loading, error }` where `user` has: + * - email, fullName, avatarUrl, authMethod, userId + * + * For compatibility with code that used `session?.user?.email` etc., + * a convenience `session` shape is also provided. + */ +export function useUser() { + return useContext(UserContext) +} + +/** + * Compatibility shim for code that used `useSession()` from NextAuth. + * Returns `{ data: session, status }` where session.user has name/email/image. + * Uses useMemo to return stable references and prevent infinite re-render loops + * when used as a useEffect dependency. + */ +export function useSession() { + const { user, loading } = useContext(UserContext) + + const session = useMemo( + () => + user + ? { + user: { + name: user.fullName, + email: user.email, + image: user.avatarUrl, + }, + } + : null, + [user?.fullName, user?.email, user?.avatarUrl] + ) + + const status: 'loading' | 'authenticated' | 'unauthenticated' = loading + ? 'loading' + : user + ? 'authenticated' + : 'unauthenticated' + + return { data: session, status } +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index da7ed9a63..466433690 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,4 +1,25 @@ -export { default } from 'next-auth/middleware' +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +/** + * Middleware that protects routes by checking for the Django session cookie. + * Replaces NextAuth's middleware — the Django session is now the single + * source of truth for authentication state. + */ +export function middleware(request: NextRequest) { + const sessionCookie = request.cookies.get('sessionid') + + if (!sessionCookie) { + const loginUrl = new URL('/login', request.url) + const fullPath = request.nextUrl.search + ? `${request.nextUrl.pathname}${request.nextUrl.search}` + : request.nextUrl.pathname + loginUrl.searchParams.set('callbackUrl', fullPath) + return NextResponse.redirect(loginUrl) + } + + return NextResponse.next() +} export const config = { matcher: [ @@ -8,7 +29,10 @@ export const config = { * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) + * - login (login page) + * - register (registration page) + * - lockbox (public lockbox viewer) */ - '/((?!api|_next/static|_next/image|favicon.ico|favicon.svg|assets|login|lockbox|api/health).*)', + '/((?!api|_next/static|_next/image|favicon.ico|favicon.svg|assets|login|register|lockbox|api/health).*)', ], } From 42ba535cce63c58d59727313272b9dc77dcd894b Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 10 Apr 2026 20:00:32 +0800 Subject: [PATCH 004/100] refactor(frontend): migrate all components from next-auth to UserContext Replace all useSession/signIn/signOut imports from next-auth/react with the new UserContext compatibility shim. SSO buttons now redirect to backend /auth/sso//authorize/ endpoints. --- .../_components/ManageUserAccessDialog.tsx | 2 +- .../app/[team]/apps/[app]/access/members/page.tsx | 2 +- frontend/app/[team]/recovery/page.tsx | 2 +- frontend/app/[team]/settings/page.tsx | 2 +- frontend/app/invite/[invite]/page.tsx | 2 +- frontend/app/signup/page.tsx | 2 +- frontend/app/webauth/[requestCode]/page.tsx | 2 +- frontend/components/LoginButton.tsx | 7 ++++--- frontend/components/UserMenu.tsx | 4 ++-- .../components/access/NetworkAccessPolicyForm.tsx | 2 +- frontend/components/auth/SignInButtons.tsx | 15 ++++++--------- frontend/components/auth/UnlockKeyringDialog.tsx | 11 ++++++++--- frontend/components/forms/UpgradeRequestForm.tsx | 2 +- frontend/components/onboarding/TeamName.tsx | 4 +--- .../settings/account/ViewRecoveryDialog.tsx | 2 +- frontend/contexts/organisationContext.tsx | 4 ++-- 16 files changed, 33 insertions(+), 32 deletions(-) diff --git a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx index 3fdcb14be..f86fd57d2 100644 --- a/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/_components/ManageUserAccessDialog.tsx @@ -10,7 +10,7 @@ import { Listbox, Transition } from '@headlessui/react' import { FaCheckCircle, FaChevronDown, FaCircle, FaCog } from 'react-icons/fa' import clsx from 'clsx' import { toast } from 'react-toastify' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { KeyringContext } from '@/contexts/keyringContext' import { userHasGlobalAccess, userHasPermission } from '@/utils/access/permissions' import { Alert } from '@/components/common/Alert' diff --git a/frontend/app/[team]/apps/[app]/access/members/page.tsx b/frontend/app/[team]/apps/[app]/access/members/page.tsx index d79240fb6..9f4491491 100644 --- a/frontend/app/[team]/apps/[app]/access/members/page.tsx +++ b/frontend/app/[team]/apps/[app]/access/members/page.tsx @@ -6,7 +6,7 @@ import { useContext, useState } from 'react' import { OrganisationMemberType } from '@/apollo/graphql' import { organisationContext } from '@/contexts/organisationContext' import { FaBan, FaSearch, FaTimesCircle } from 'react-icons/fa' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { Avatar } from '@/components/common/Avatar' import { userHasPermission } from '@/utils/access/permissions' import { RoleLabel } from '@/components/users/RoleLabel' diff --git a/frontend/app/[team]/recovery/page.tsx b/frontend/app/[team]/recovery/page.tsx index 77e92243f..7f0ee6ef3 100644 --- a/frontend/app/[team]/recovery/page.tsx +++ b/frontend/app/[team]/recovery/page.tsx @@ -8,7 +8,7 @@ import { useContext, useEffect, useState } from 'react' import { MdContentPaste, MdOutlineKey } from 'react-icons/md' import { useMutation } from '@apollo/client' import UpdateWrappedSecrets from '@/graphql/mutations/organisation/updateUserWrappedSecrets.gql' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { toast } from 'react-toastify' import { useRouter } from 'next/navigation' import UserMenu from '@/components/UserMenu' diff --git a/frontend/app/[team]/settings/page.tsx b/frontend/app/[team]/settings/page.tsx index 194701a69..3b905bd94 100644 --- a/frontend/app/[team]/settings/page.tsx +++ b/frontend/app/[team]/settings/page.tsx @@ -12,7 +12,7 @@ import { TransferOwnershipSection } from '@/components/settings/organisation/Tra import { userHasPermission } from '@/utils/access/permissions' import { Tab } from '@headlessui/react' import clsx from 'clsx' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { Fragment, useContext, useEffect, useState } from 'react' import { FaMoon, FaSun } from 'react-icons/fa' import Spinner from '@/components/common/Spinner' diff --git a/frontend/app/invite/[invite]/page.tsx b/frontend/app/invite/[invite]/page.tsx index a086fbc3b..ef4d6ceae 100644 --- a/frontend/app/invite/[invite]/page.tsx +++ b/frontend/app/invite/[invite]/page.tsx @@ -15,7 +15,7 @@ import { AccountRecovery } from '@/components/onboarding/AccountRecovery' import { MdKey, MdOutlinePassword } from 'react-icons/md' import { toast } from 'react-toastify' import { OrganisationMemberInviteType } from '@/apollo/graphql' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { copyRecoveryKit, generateRecoveryPdf } from '@/utils/recovery' import { LogoMark } from '@/components/common/LogoMark' import { setDevicePassword } from '@/utils/localStorage' diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index b42086c00..ea1e5d493 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -8,7 +8,7 @@ import { MdGroups, MdKey, MdOutlinePassword } from 'react-icons/md' import { TeamName } from '@/components/onboarding/TeamName' import { AccountRecovery } from '@/components/onboarding/AccountRecovery' import { AccountPassword } from '@/components/onboarding/AccountPassword' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { toast } from 'react-toastify' import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import { useRouter } from 'next/navigation' diff --git a/frontend/app/webauth/[requestCode]/page.tsx b/frontend/app/webauth/[requestCode]/page.tsx index a734770b1..27188b04e 100644 --- a/frontend/app/webauth/[requestCode]/page.tsx +++ b/frontend/app/webauth/[requestCode]/page.tsx @@ -26,7 +26,7 @@ import { useMutation } from '@apollo/client' import { Disclosure, Transition } from '@headlessui/react' import axios from 'axios' import clsx from 'clsx' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { useRouter } from 'next/navigation' import { useContext, useEffect, useState } from 'react' import { FaChevronRight, FaExclamationTriangle, FaCheckCircle, FaShieldAlt } from 'react-icons/fa' diff --git a/frontend/components/LoginButton.tsx b/frontend/components/LoginButton.tsx index 024bb7813..f49854e75 100644 --- a/frontend/components/LoginButton.tsx +++ b/frontend/components/LoginButton.tsx @@ -1,6 +1,7 @@ 'use client' -import { useSession, signIn, signOut } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' +import { handleSignout } from '@/apollo/client' import { Button } from './common/Button' export default function Component() { @@ -9,7 +10,7 @@ export default function Component() { return ( <> Signed in as {session.user!.email}
- @@ -18,7 +19,7 @@ export default function Component() { return ( <> Not signed in
- diff --git a/frontend/components/UserMenu.tsx b/frontend/components/UserMenu.tsx index 5668321b5..9d423b03b 100644 --- a/frontend/components/UserMenu.tsx +++ b/frontend/components/UserMenu.tsx @@ -2,7 +2,7 @@ import { Menu, Transition } from '@headlessui/react' import { Fragment, useContext } from 'react' -import { useSession } from 'next-auth/react' +import { useSession } from '@/contexts/userContext' import { MdLogout } from 'react-icons/md' import { handleSignout } from '@/apollo/client' import { Button } from './common/Button' @@ -91,7 +91,7 @@ export default function UserMenu() { + ))} + +
+
+ or +
+
+ + )} +
+
+ setEmail(e.target.value)} + required + autoFocus + className="w-full" + />
- -
+ + +
+ + Create an account + +
+ )} - {providers.length > 0 ? ( -
- {providerButtons - .filter((p) => providers.includes(p.id)) - .map((provider) => ( - - ))} -
- ) : ( - -
-

- No authentication providers configured -

-

- Please contact your administrator, or refer to the{' '} - - documentation - {' '} - to correctly configure SSO providers. -

+ + {/* Step 2a: Password login */} + {step === 'password' && ( +
+ + +
+ setPassword(e.target.value)} + required + minLength={16} + autoFocus + className="custom w-full text-zinc-800 font-mono dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md ph-no-capture" + /> +
- + + +
+ + Create an account + +
+
)} - {isCloudHosted() && ( -

- By continuing, you are agreeing to our{' '} - + + +

+ This account uses SSO. Continue with your identity provider. +

+ + +
)}
- )} - - + + {isCloudHosted() && ( +

+ By continuing, you are agreeing to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

+ )} + + )} + ) } From 1914399f4391d7caab37e659916decb2a73078e5 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:36:01 +0800 Subject: [PATCH 027/100] feat: add password signup page with email verification New signup page with full name, email, password fields. Handles client-side key derivation, shows email verification screen on success, and supports resend with single-use disable. --- frontend/app/signup/page.tsx | 549 ++++++++++++----------------------- 1 file changed, 193 insertions(+), 356 deletions(-) diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index ea1e5d493..0e1cdcc47 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -1,396 +1,233 @@ 'use client' import { Button } from '@/components/common/Button' +import { Input } from '@/components/common/Input' import { HeroPattern } from '@/components/common/HeroPattern' -import { Step, Stepper } from '@/components/onboarding/Stepper' +import { LogoWordMark } from '@/components/common/LogoWordMark' import { useEffect, useState } from 'react' -import { MdGroups, MdKey, MdOutlinePassword } from 'react-icons/md' -import { TeamName } from '@/components/onboarding/TeamName' -import { AccountRecovery } from '@/components/onboarding/AccountRecovery' -import { AccountPassword } from '@/components/onboarding/AccountPassword' -import { useSession } from '@/contexts/userContext' import { toast } from 'react-toastify' -import { useLazyQuery, useMutation, useQuery } from '@apollo/client' -import { useRouter } from 'next/navigation' -import { GetLicenseData } from '@/graphql/queries/organisation/getLicense.gql' -import { CreateOrg } from '@/graphql/mutations/createOrganisation.gql' -import GetOrganisations from '@/graphql/queries/getOrganisations.gql' -import CheckOrganisationNameAvailability from '@/graphql/queries/organisation/checkOrgNameAvailable.gql' -import { copyRecoveryKit, generateRecoveryPdf } from '@/utils/recovery' -import { setDevicePassword } from '@/utils/localStorage' -import { LogoMark } from '@/components/common/LogoMark' -import { - organisationSeed, - organisationKeyring, - deviceVaultKey, - encryptAccountKeyring, - encryptAccountRecovery, -} from '@/utils/crypto' -import { createApplication } from '@/utils/app' -import { License } from '@/ee/billing/License' - -const bip39 = require('bip39') - -const Onboard = () => { - const { data: session } = useSession() - const [teamNameLock, setTeamNameLock] = useState(false) - const [teamName, setTeamName] = useState('') - const [pw, setPw] = useState('') - const [pw2, setPw2] = useState('') - const [savePassword, setSavePassword] = useState(true) - const [mnemonic, setMnemonic] = useState('') - const [orgId, setOrgId] = useState('') - const [inputs, setInputs] = useState>([]) - const [step, setStep] = useState(0) - - const { data: licenseData } = useQuery(GetLicenseData) - const [createOrganisation, { data, loading, error }] = useMutation(CreateOrg) - const [checkOrganisationNameAvailability] = useLazyQuery(CheckOrganisationNameAvailability) - const [isloading, setIsLoading] = useState(false) - const [recoveryDownloaded, setRecoveryDownloaded] = useState(false) - const [success, setSuccess] = useState(false) - +import { useRouter, useSearchParams } from 'next/navigation' +import { useSession } from '@/contexts/userContext' +import { FaCheckCircle } from 'react-icons/fa' +import Link from 'next/link' +import axios from 'axios' +import { UrlUtils } from '@/utils/auth' +import { deviceVaultKey, passwordAuthHash } from '@/utils/crypto' +import { ModeToggle } from '@/components/common/ModeToggle' +import { FaSun, FaMoon } from 'react-icons/fa6' +import { InstanceInfo } from '@/components/InstanceInfo' + +const Signup = () => { + const { status } = useSession() const router = useRouter() + const searchParams = useSearchParams() - const errorToast = (message: string) => { - toast.error(message) - } + const [email, setEmail] = useState('') + const [fullName, setFullName] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const [pendingVerification, setPendingVerification] = useState(false) useEffect(() => { - if (licenseData?.license?.organisationName) { - setTeamName(licenseData.license.organisationName) - setTeamNameLock(true) - - if (licenseData.license?.organisationOwner?.email === session?.user?.email) { - router.push(`/`) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [licenseData]) + // Pre-fill email from query param (from login "Create an account" link) + const emailParam = searchParams?.get('email') + if (emailParam) setEmail(emailParam) + }, [searchParams]) - const licenseActivated = () => licenseData?.license?.isActivated + useEffect(() => { + // Authenticated users with orgs should go home, not signup + if (status === 'authenticated') router.push('/') + }, [status, router]) - const steps: Step[] = [ - { - index: 0, - name: teamNameLock ? 'Organisation setup' : 'Organisation Name', - icon: , - title: teamNameLock ? 'Set up your organisation' : 'Choose a name for your organisation', - description: teamNameLock ? ( - <> - ) : ( -
- Your organisation name can be alphanumeric. - -
[a-zA-Z0-9]
-
-
- ), - }, - { - index: 1, - name: 'Sudo Password', - icon: , - title: 'Set a sudo password', - description: - 'This will be used to encrypt your account keys. You may need to enter this password to unlock your workspace when logging in.', - }, - { - index: 2, - name: 'Account recovery', - icon: , - title: 'Account Recovery', - description: - 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', - }, - ] + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault() - const validateCurrentStep = async () => { - if (step === 0) { - if (!teamName) { - errorToast('Please enter an organisation name') - return false - } else { - const { data } = await checkOrganisationNameAvailability({ variables: { name: teamName } }) - if (!data.organisationNameAvailable) { - errorToast('This organisation name is taken!') - return false - } - } - return true - } else if (step === 1) { - if (pw !== pw2) { - errorToast("Passwords don't match") - return false - } - } else if (step === 2) { - if (!recoveryDownloaded) { - errorToast('Please download the your account recovery kit!') - return false - } + if (password !== confirmPassword) { + toast.error("Passwords don't match") + return } - return true - } - - const computeAccountKeys = () => { - return new Promise<{ publicKey: string; encryptedKeyring: string; encryptedMnemonic: string }>( - (resolve) => { - setTimeout(async () => { - const accountSeed = await organisationSeed(mnemonic, orgId) - - const accountKeyRing = await organisationKeyring(accountSeed) - - const deviceKey = await deviceVaultKey(pw, session?.user?.email!) - const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey) - - const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey) - - resolve({ - publicKey: accountKeyRing.publicKey, - encryptedKeyring, - encryptedMnemonic, - }) - }, 1000) - } - ) - } + if (password.length < 16) { + toast.error('Password must be at least 16 characters') + return + } - const handleDownloadRecoveryKit = async () => { - toast - .promise( - generateRecoveryPdf( - mnemonic, - session?.user?.email!, - teamName, - session?.user?.name || undefined + setLoading(true) + try { + const trimmedEmail = email.toLowerCase().trim() + const masterKey = await deviceVaultKey(password, trimmedEmail) + const authHash = await passwordAuthHash(masterKey) + + const response = await axios.post( + UrlUtils.makeUrl( + process.env.NEXT_PUBLIC_BACKEND_API_BASE!, + 'auth', + 'password', + 'register' ), { - pending: 'Generating recovery kit', - success: 'Downloaded recovery kit', - } + email: trimmedEmail, + fullName: fullName.trim(), + authHash, + }, + { withCredentials: true } ) - .then(() => setRecoveryDownloaded(true)) - } - - const handleCopyRecoveryKit = () => { - copyRecoveryKit(mnemonic, session?.user?.email!, teamName, session?.user?.name || undefined) - setRecoveryDownloaded(true) - } - - const handleAccountInit = async () => { - return new Promise(async (resolve, reject) => { - try { - setIsLoading(true) - const { publicKey, encryptedKeyring, encryptedMnemonic } = await computeAccountKeys() - // Create organization - const result = await createOrganisation({ - variables: { - id: orgId, - name: teamName, - identityKey: publicKey, - wrappedKeyring: encryptedKeyring, - wrappedRecovery: encryptedMnemonic, - }, - refetchQueries: [{ query: GetOrganisations }], - }) - - if (!result.data?.createOrganisation?.organisation) { - throw new Error('Organization creation failed. Please try again.') - } - - const newOrg = result.data.createOrganisation.organisation - - // Save password if option selected - if (savePassword && newOrg.memberId) { - setDevicePassword(newOrg.memberId, pw) - } - - // Create example app with environments - try { - const accountKeyRing = await organisationKeyring(await organisationSeed(mnemonic, orgId)) - if ( - !accountKeyRing?.publicKey || - !accountKeyRing?.privateKey || - !accountKeyRing?.symmetricKey - ) { - throw new Error('Failed to generate account keyring') - } - - // Ensure we have all required fields for the owner user - if (!newOrg.memberId) { - throw new Error('Missing member ID') - } - - // Create the owner user object with proper role structure - const ownerUser = { - id: newOrg.memberId, - identityKey: publicKey, - role: { - name: 'Owner', - permissions: [], - }, - } - - await createApplication({ - name: 'example-app', - organisation: newOrg, - keyring: { - publicKey: accountKeyRing.publicKey, - privateKey: accountKeyRing.privateKey, - symmetricKey: accountKeyRing.symmetricKey, - }, - globalAccessUsers: [ownerUser], - createExampleSecrets: true, - }) - } catch (appError) { - console.error('Failed to create example app:', appError) - // Don't throw - allow account creation to succeed even if app creation fails - } - - setIsLoading(false) - setSuccess(true) // Only set success after everything is complete - resolve(true) - } catch (e) { - setIsLoading(false) - reject(e) + if (response.data.verificationSkipped) { + toast.success('Account created! You can now log in.') + router.push('/login') + } else { + setPendingVerification(true) } - }) - } - - const incrementStep = async (event: { preventDefault: () => void }) => { - event.preventDefault() - - const isFormValid = await validateCurrentStep() - if (step !== steps.length - 1 && isFormValid) setStep(step + 1) - if (step === steps.length - 1 && isFormValid) { - try { - await toast.promise(handleAccountInit, { - pending: 'Setting up your account', - success: 'Account setup complete!', - error: 'Failed to setup account', - }) - - // Only redirect after everything is successful - router.push(`/${teamName}/apps`) - } catch (error) { - console.error('Setup failed:', error) - setSuccess(false) - // Error is already shown by toast + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data?.error) { + toast.error(err.response.data.error) + } else { + toast.error('Registration failed. Please try again.') } + } finally { + setLoading(false) } } - const decrementStep = () => { - if (step !== 0) setStep(step - 1) - } - - useEffect(() => { - setMnemonic(bip39.generateMnemonic(256)) - const id = crypto.randomUUID() - setOrgId(id) - }, []) - - useEffect(() => { - if (data?.createOrganisation?.organisation) { - setSuccess(true) + const [resending, setResending] = useState(false) + const [resent, setResent] = useState(false) + + const handleResendVerification = async () => { + setResending(true) + try { + await axios.post( + UrlUtils.makeUrl( + process.env.NEXT_PUBLIC_BACKEND_API_BASE!, + 'auth', + 'verify-email', + 'resend' + ), + { email: email.toLowerCase().trim() }, + { withCredentials: true } + ) + toast.success('Verification email sent!') + setResent(true) + } catch { + toast.error('Failed to resend. Please try again later.') + } finally { + setResending(false) } - }, [data, router]) + } - useEffect(() => { - setInputs([...Array(mnemonic.split(' ').length)].map(() => '')) - }, [mnemonic]) + if (pendingVerification) { + return ( +
+ +
+ +
+

+ Check your email +

+

+ We sent a verification link to{' '} + {email}. Click the link to + activate your account. +

+
+ +
+
+ ) + } return ( -
- - - {!licenseActivated() ? ( -
-
-
- {step >= 0 && ( -
- Welcome to Phase -
- )} - + <> +
+
+
+ +
+
+
+
+ + + +
+
+
- {licenseData?.license && } - - {step === 0 && ( - - )} - {step === 1 && ( - +
+ +
+
Create your account
+ +
+ + - )} - {step === 2 && ( - - )} - -
-
- {step !== 0 && ( - - )} -
-
- +
+ - {step === steps.length - 1 ? 'Finish' : 'Next'} - + Already have an account? Log in +
-
- -
- ) : ( -
- - -
-
- Welcome to Phase at {licenseData.license.customerName} -
-

- Your organisation admin has already set up this Phase instance. -

-

- Please contact{' '} - - - {licenseData.license.organisationOwner.fullName} - {' '} - ({licenseData.license.organisationOwner.email}){' '} - - for an invite to join this workspace. -

+
- )} -
+ + ) } -export default Onboard +export default Signup From 2c83a08c884844864ec5233e03ea61285fcb9ab3 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:36:19 +0800 Subject: [PATCH 028/100] feat: add 3-step onboarding flow for all users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All users (password and SSO) go through the same onboard: Organisation Name → Sudo Password → Account Recovery. No sessionStorage password caching. --- frontend/app/onboard/head.tsx | 9 + frontend/app/onboard/layout.tsx | 12 + frontend/app/onboard/page.tsx | 411 ++++++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 frontend/app/onboard/head.tsx create mode 100644 frontend/app/onboard/layout.tsx create mode 100644 frontend/app/onboard/page.tsx diff --git a/frontend/app/onboard/head.tsx b/frontend/app/onboard/head.tsx new file mode 100644 index 000000000..772e640e7 --- /dev/null +++ b/frontend/app/onboard/head.tsx @@ -0,0 +1,9 @@ +export default function Head() { + return ( + <> + + + + + ) +} diff --git a/frontend/app/onboard/layout.tsx b/frontend/app/onboard/layout.tsx new file mode 100644 index 000000000..0c12551de --- /dev/null +++ b/frontend/app/onboard/layout.tsx @@ -0,0 +1,12 @@ +import '@/app/globals.css' +import OnboardingNavbar from '@/components/layout/OnboardingNavbar' +import UserMenu from '@/components/UserMenu' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} diff --git a/frontend/app/onboard/page.tsx b/frontend/app/onboard/page.tsx new file mode 100644 index 000000000..8cbef7371 --- /dev/null +++ b/frontend/app/onboard/page.tsx @@ -0,0 +1,411 @@ +'use client' + +import { Button } from '@/components/common/Button' +import { HeroPattern } from '@/components/common/HeroPattern' +import { Step, Stepper } from '@/components/onboarding/Stepper' +import { useEffect, useState } from 'react' +import { MdGroups, MdKey, MdOutlinePassword } from 'react-icons/md' +import { TeamName } from '@/components/onboarding/TeamName' +import { AccountRecovery } from '@/components/onboarding/AccountRecovery' +import { AccountPassword } from '@/components/onboarding/AccountPassword' +import { useSession } from '@/contexts/userContext' +import { useUser } from '@/contexts/userContext' +import { toast } from 'react-toastify' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' +import { useRouter } from 'next/navigation' +import { GetLicenseData } from '@/graphql/queries/organisation/getLicense.gql' +import { CreateOrg } from '@/graphql/mutations/createOrganisation.gql' +import GetOrganisations from '@/graphql/queries/getOrganisations.gql' +import CheckOrganisationNameAvailability from '@/graphql/queries/organisation/checkOrgNameAvailable.gql' +import { copyRecoveryKit, generateRecoveryPdf } from '@/utils/recovery' +import { setDevicePassword } from '@/utils/localStorage' +import { LogoMark } from '@/components/common/LogoMark' +import { + organisationSeed, + organisationKeyring, + deviceVaultKey, + encryptAccountKeyring, + encryptAccountRecovery, +} from '@/utils/crypto' +import { createApplication } from '@/utils/app' +import { License } from '@/ee/billing/License' + +const bip39 = require('bip39') + +const Onboard = () => { + const { data: session } = useSession() + const { user } = useUser() + const [teamNameLock, setTeamNameLock] = useState(false) + const [teamName, setTeamName] = useState('') + const [pw, setPw] = useState('') + const [pw2, setPw2] = useState('') + const [savePassword, setSavePassword] = useState(true) + const [mnemonic, setMnemonic] = useState('') + const [orgId, setOrgId] = useState('') + const [inputs, setInputs] = useState>([]) + const [step, setStep] = useState(0) + + const { data: licenseData } = useQuery(GetLicenseData) + const [createOrganisation, { data, loading, error }] = useMutation(CreateOrg) + const [checkOrganisationNameAvailability] = useLazyQuery(CheckOrganisationNameAvailability) + const [isloading, setIsLoading] = useState(false) + const [recoveryDownloaded, setRecoveryDownloaded] = useState(false) + const [success, setSuccess] = useState(false) + + const router = useRouter() + + const errorToast = (message: string) => { + toast.error(message) + } + + useEffect(() => { + if (licenseData?.license?.organisationName) { + setTeamName(licenseData.license.organisationName) + setTeamNameLock(true) + + if (licenseData.license?.organisationOwner?.email === session?.user?.email) { + router.push(`/`) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [licenseData]) + + const licenseActivated = () => licenseData?.license?.isActivated + + const ssoSteps: Step[] = [ + { + index: 0, + name: teamNameLock ? 'Organisation setup' : 'Organisation Name', + icon: , + title: teamNameLock ? 'Set up your organisation' : 'Choose a name for your organisation', + description: teamNameLock ? ( + <> + ) : ( +
+ Your organisation name can be alphanumeric. + +
[a-zA-Z0-9]
+
+
+ ), + }, + { + index: 1, + name: 'Sudo Password', + icon: , + title: 'Set a sudo password', + description: + 'This will be used to encrypt your account keys. You may need to enter this password to unlock your workspace when logging in.', + }, + { + index: 2, + name: 'Account recovery', + icon: , + title: 'Account Recovery', + description: + 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', + }, + ] + + const steps = ssoSteps + + const validateCurrentStep = async () => { + if (step === 0) { + if (!teamName) { + errorToast('Please enter an organisation name') + return false + } else { + const { data } = await checkOrganisationNameAvailability({ variables: { name: teamName } }) + if (!data.organisationNameAvailable) { + errorToast('This organisation name is taken!') + return false + } + } + return true + } + + // Sudo password step (step 1) + if (step === 1) { + if (pw !== pw2) { + errorToast("Passwords don't match") + return false + } + } + + // Recovery step (last step for both flows) + if (step === steps.length - 1) { + if (!recoveryDownloaded) { + errorToast('Please download the your account recovery kit!') + return false + } + } + + return true + } + + const computeAccountKeys = () => { + return new Promise<{ publicKey: string; encryptedKeyring: string; encryptedMnemonic: string }>( + (resolve) => { + setTimeout(async () => { + const accountSeed = await organisationSeed(mnemonic, orgId) + + const accountKeyRing = await organisationKeyring(accountSeed) + + const deviceKey = await deviceVaultKey(pw, session?.user?.email!) + + const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey) + + const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey) + + resolve({ + publicKey: accountKeyRing.publicKey, + encryptedKeyring, + encryptedMnemonic, + }) + }, 1000) + } + ) + } + + const handleDownloadRecoveryKit = async () => { + toast + .promise( + generateRecoveryPdf( + mnemonic, + session?.user?.email!, + teamName, + session?.user?.name || undefined + ), + { + pending: 'Generating recovery kit', + success: 'Downloaded recovery kit', + } + ) + .then(() => setRecoveryDownloaded(true)) + } + + const handleCopyRecoveryKit = () => { + copyRecoveryKit(mnemonic, session?.user?.email!, teamName, session?.user?.name || undefined) + setRecoveryDownloaded(true) + } + + const handleAccountInit = async () => { + return new Promise(async (resolve, reject) => { + try { + setIsLoading(true) + const { publicKey, encryptedKeyring, encryptedMnemonic } = await computeAccountKeys() + + // Create organization + const result = await createOrganisation({ + variables: { + id: orgId, + name: teamName, + identityKey: publicKey, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, + }, + refetchQueries: [{ query: GetOrganisations }], + }) + + if (!result.data?.createOrganisation?.organisation) { + throw new Error('Organization creation failed. Please try again.') + } + + const newOrg = result.data.createOrganisation.organisation + + // Save password if option selected + if (savePassword && newOrg.memberId) { + setDevicePassword(newOrg.memberId, pw) + } + + // Create example app with environments + try { + const accountKeyRing = await organisationKeyring(await organisationSeed(mnemonic, orgId)) + if ( + !accountKeyRing?.publicKey || + !accountKeyRing?.privateKey || + !accountKeyRing?.symmetricKey + ) { + throw new Error('Failed to generate account keyring') + } + + // Ensure we have all required fields for the owner user + if (!newOrg.memberId) { + throw new Error('Missing member ID') + } + + // Create the owner user object with proper role structure + const ownerUser = { + id: newOrg.memberId, + identityKey: publicKey, + role: { + name: 'Owner', + permissions: [], + }, + } + + await createApplication({ + name: 'example-app', + organisation: newOrg, + keyring: { + publicKey: accountKeyRing.publicKey, + privateKey: accountKeyRing.privateKey, + symmetricKey: accountKeyRing.symmetricKey, + }, + globalAccessUsers: [ownerUser], + createExampleSecrets: true, + }) + } catch (appError) { + console.error('Failed to create example app:', appError) + // Don't throw - allow account creation to succeed even if app creation fails + } + + setIsLoading(false) + setSuccess(true) // Only set success after everything is complete + resolve(true) + } catch (e) { + setIsLoading(false) + reject(e) + } + }) + } + + const incrementStep = async (event: { preventDefault: () => void }) => { + event.preventDefault() + + const isFormValid = await validateCurrentStep() + if (step !== steps.length - 1 && isFormValid) setStep(step + 1) + if (step === steps.length - 1 && isFormValid) { + try { + await toast.promise(handleAccountInit, { + pending: 'Setting up your account', + success: 'Account setup complete!', + error: 'Failed to setup account', + }) + + // Only redirect after everything is successful + router.push(`/${teamName}/apps`) + } catch (error) { + console.error('Setup failed:', error) + setSuccess(false) + // Error is already shown by toast + } + } + } + + const decrementStep = () => { + if (step !== 0) setStep(step - 1) + } + + useEffect(() => { + setMnemonic(bip39.generateMnemonic(256)) + const id = crypto.randomUUID() + setOrgId(id) + }, []) + + useEffect(() => { + if (data?.createOrganisation?.organisation) { + setSuccess(true) + } + }, [data, router]) + + useEffect(() => { + setInputs([...Array(mnemonic.split(' ').length)].map(() => '')) + }, [mnemonic]) + + // Determine which content to show for the current step + const isRecoveryStep = step === steps.length - 1 + const isSudoPasswordStep = step === 1 + + return ( +
+ + + {!licenseActivated() ? ( +
+
+
+ {step >= 0 && ( +
+ Welcome to Phase +
+ )} + +
+ + {licenseData?.license && } + + {step === 0 && ( + + )} + {isSudoPasswordStep && ( + + )} + {isRecoveryStep && ( + + )} + +
+
+ {step !== 0 && ( + + )} +
+
+ +
+
+ +
+ ) : ( +
+ + +
+
+ Welcome to Phase at {licenseData.license.customerName} +
+

+ Your organisation admin has already set up this Phase instance. +

+

+ Please contact{' '} + + + {licenseData.license.organisationOwner.fullName} + {' '} + ({licenseData.license.organisationOwner.email}){' '} + + for an invite to join this workspace. +

+
+
+ )} +
+ ) +} + +export default Onboard From dfdfb73a4fe59c3520d0f49843863b9c717bf93e Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:36:35 +0800 Subject: [PATCH 029/100] feat: add password change section in account settings - Add ChangePasswordSection component for password users - Re-wraps all org keyrings with the new password - Hidden for SSO users (no usable password) --- frontend/app/[team]/settings/page.tsx | 2 + .../account/ChangePasswordSection.tsx | 154 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 frontend/components/settings/account/ChangePasswordSection.tsx diff --git a/frontend/app/[team]/settings/page.tsx b/frontend/app/[team]/settings/page.tsx index 3b905bd94..af17be6d1 100644 --- a/frontend/app/[team]/settings/page.tsx +++ b/frontend/app/[team]/settings/page.tsx @@ -17,6 +17,7 @@ import { Fragment, useContext, useEffect, useState } from 'react' import { FaMoon, FaSun } from 'react-icons/fa' import Spinner from '@/components/common/Spinner' import { ReleaseInfo } from '@/components/ReleaseInfo' +import { ChangePasswordSection } from '@/components/settings/account/ChangePasswordSection' export default function Settings({ params }: { params: { team: string } }) { const searchParams = useSearchParams() @@ -132,6 +133,7 @@ export default function Settings({ params }: { params: { team: string } }) {
Recovery
+
diff --git a/frontend/components/settings/account/ChangePasswordSection.tsx b/frontend/components/settings/account/ChangePasswordSection.tsx new file mode 100644 index 000000000..a84c361b7 --- /dev/null +++ b/frontend/components/settings/account/ChangePasswordSection.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useState, useContext } from 'react' +import { Button } from '@/components/common/Button' +import { Input } from '@/components/common/Input' +import { toast } from 'react-toastify' +import { useSession } from '@/contexts/userContext' +import { useUser } from '@/contexts/userContext' +import { organisationContext } from '@/contexts/organisationContext' +import { + deviceVaultKey, + passwordAuthHash, + encryptAccountKeyring, + encryptAccountRecovery, + decryptAccountKeyring, + decryptAccountRecovery, +} from '@/utils/crypto' +import { setDevicePassword } from '@/utils/localStorage' +import axios from 'axios' +import { UrlUtils } from '@/utils/auth' + +export function ChangePasswordSection() { + const { user } = useUser() + const { data: session } = useSession() + const { activeOrganisation, organisations } = useContext(organisationContext) + + const [currentPw, setCurrentPw] = useState('') + const [newPw, setNewPw] = useState('') + const [confirmPw, setConfirmPw] = useState('') + const [loading, setLoading] = useState(false) + + // Only show for password users + if (!user || user.authMethod !== 'password') return null + + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault() + + if (newPw !== confirmPw) { + toast.error("New passwords don't match") + return + } + + if (newPw.length < 16) { + toast.error('Password must be at least 16 characters') + return + } + + const email = session?.user?.email + if (!email || !organisations) return + + setLoading(true) + try { + const oldMasterKey = await deviceVaultKey(currentPw, email) + const oldAuthHash = await passwordAuthHash(oldMasterKey) + const newMasterKey = await deviceVaultKey(newPw, email) + const newAuthHash = await passwordAuthHash(newMasterKey) + + // Re-encrypt keyring + recovery for EVERY org the user belongs to + const orgKeys: { orgId: string; wrappedKeyring: string; wrappedRecovery: string }[] = [] + + for (const org of organisations) { + if (!org.keyring) continue + + const keyring = await decryptAccountKeyring(org.keyring, oldMasterKey) + const newWrappedKeyring = await encryptAccountKeyring(keyring, newMasterKey) + + let newWrappedRecovery = '' + if (org.recovery) { + const mnemonic = await decryptAccountRecovery(org.recovery, oldMasterKey) + newWrappedRecovery = await encryptAccountRecovery(mnemonic, newMasterKey) + } + + orgKeys.push({ + orgId: org.id, + wrappedKeyring: newWrappedKeyring, + wrappedRecovery: newWrappedRecovery, + }) + } + + await axios.post( + UrlUtils.makeUrl( + process.env.NEXT_PUBLIC_BACKEND_API_BASE!, + 'auth', + 'password', + 'change' + ), + { + currentAuthHash: oldAuthHash, + newAuthHash: newAuthHash, + orgKeys, + }, + { withCredentials: true } + ) + + // Update stored device password for all orgs + for (const org of organisations) { + if (org.memberId) { + setDevicePassword(org.memberId, newPw) + } + } + + toast.success('Password changed successfully') + setCurrentPw('') + setNewPw('') + setConfirmPw('') + } catch (err) { + if (axios.isAxiosError(err) && err.response?.data?.error) { + toast.error(err.response.data.error) + } else { + toast.error('Failed to change password. Check your current password and try again.') + } + } finally { + setLoading(false) + } + } + + return ( +
+
Change password
+
+ + + + +
+
+ ) +} From f6d6f7bb3e15a0904de15824216c095dff52460e Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:36:54 +0800 Subject: [PATCH 030/100] feat: update account recovery to reset password via mnemonic Recovery flow now resets the login password in addition to re-wrapping the org keyring, using server-side identity key verification to prove mnemonic possession. --- frontend/app/[team]/recovery/page.tsx | 39 ++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/frontend/app/[team]/recovery/page.tsx b/frontend/app/[team]/recovery/page.tsx index 7f0ee6ef3..117442023 100644 --- a/frontend/app/[team]/recovery/page.tsx +++ b/frontend/app/[team]/recovery/page.tsx @@ -21,12 +21,17 @@ import { organisationSeed, organisationKeyring, deviceVaultKey, + passwordAuthHash, encryptAccountKeyring, encryptAccountRecovery, } from '@/utils/crypto' +import { useUser } from '@/contexts/userContext' +import axios from 'axios' +import { UrlUtils } from '@/utils/auth' export default function Recovery({ params }: { params: { team: string } }) { const { data: session } = useSession() + const { user } = useUser() const [inputs, setInputs] = useState>([]) const [pw, setPw] = useState('') const [pw2, setPw2] = useState('') @@ -88,13 +93,33 @@ export default function Recovery({ params }: { params: { team: string } }) { setKeyring(accountKeyRing) - await updateWrappedSecrets({ - variables: { - orgId: org!.id, - wrappedKeyring: encryptedKeyring, - wrappedRecovery: encryptedMnemonic, - }, - }) + if (user?.authMethod === 'password') { + const newAuthHash = await passwordAuthHash(deviceKey) + await axios.post( + UrlUtils.makeUrl( + process.env.NEXT_PUBLIC_BACKEND_API_BASE!, + 'auth', + 'password', + 'reset-via-recovery' + ), + { + newAuthHash, + identityKey: accountKeyRing.publicKey, + orgId: org!.id, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, + }, + { withCredentials: true } + ) + } else { + await updateWrappedSecrets({ + variables: { + orgId: org!.id, + wrappedKeyring: encryptedKeyring, + wrappedRecovery: encryptedMnemonic, + }, + }) + } if (savePassword) { setDevicePassword(org?.memberId!, pw) From c4e054396d97b1eda84059745c69beca47d68c55 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:37:12 +0800 Subject: [PATCH 031/100] fix: filter custom props from Input component to prevent React warnings Destructure setValue and secret from restProps so they are not passed through to the underlying HTML input element. --- frontend/components/common/Input.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/components/common/Input.tsx b/frontend/components/common/Input.tsx index 05c2ac885..2a9805064 100644 --- a/frontend/components/common/Input.tsx +++ b/frontend/components/common/Input.tsx @@ -13,7 +13,7 @@ interface InputProps extends React.InputHTMLAttributes { // Use forwardRef to allow refs to be passed to the component export const Input = forwardRef((props, ref) => { - const { value, setValue, label, secret, labelClassName } = props + const { value, setValue, label, secret, labelClassName, ...restProps } = props const [showValue, setShowValue] = useState(false) @@ -22,24 +22,24 @@ export const Input = forwardRef((props, ref) => { {label && ( )}
setValue(e.target.value)} className={clsx( 'custom w-full text-zinc-800 dark:text-white bg-zinc-100 dark:bg-zinc-800 rounded-md', secret ? 'ph-no-capture' : '', - props.readOnly || props.disabled ? 'opacity-60 cursor-not-allowed' : '', - props.className + restProps.readOnly || restProps.disabled ? 'opacity-60 cursor-not-allowed' : '', + restProps.className )} /> {secret && ( From 02348af851420d23646cfd30b7f43ef23b98a995 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 00:37:32 +0800 Subject: [PATCH 032/100] chore: update auth references and remove stale /register path - Remove dead /register from public path arrays - Update session handling, middleware, and navigation for cookie-based auth (replacing NextAuth) - Update userContext tests --- frontend/apollo/client.ts | 4 +++- frontend/app/[team]/layout.tsx | 2 +- frontend/app/page.tsx | 2 +- frontend/app/webauth/[requestCode]/page.tsx | 2 +- frontend/components/layout/Sidebar.tsx | 2 +- frontend/contexts/userContext.tsx | 2 +- frontend/middleware.ts | 2 +- frontend/tests/contexts/userContext.test.tsx | 22 ++++++++++++++++++++ frontend/utils/navigation.ts | 3 +++ 9 files changed, 34 insertions(+), 7 deletions(-) diff --git a/frontend/apollo/client.ts b/frontend/apollo/client.ts index 297d9fbca..6fdfd7650 100644 --- a/frontend/apollo/client.ts +++ b/frontend/apollo/client.ts @@ -48,7 +48,9 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { if (networkError) { console.log(`[Network error]: ${networkError}`) - if (networkError.message.includes('403')) handleSignout() + const publicPaths = ['/login', '/signup', '/lockbox'] + const isPublicPage = publicPaths.some((p) => window.location.pathname.startsWith(p)) + if (networkError.message.includes('403') && !isPublicPage) handleSignout() } }) diff --git a/frontend/app/[team]/layout.tsx b/frontend/app/[team]/layout.tsx index aa3c003a0..bee7cb4e7 100644 --- a/frontend/app/[team]/layout.tsx +++ b/frontend/app/[team]/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ if (!loading && organisations !== null) { // if there are no organisations for this user, send to onboarding if (organisations.length === 0) { - router.push('/signup') + router.push('/onboard') } // try and get org being accessed from route params in the list of organisations for this user diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 469f36b56..9c6e9287e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -32,7 +32,7 @@ export default function Home() { useEffect(() => { if (!loading && organisations !== null) { // if there is no org membership, send to onboarding - if (organisations.length === 0) router.push('/signup') + if (organisations.length === 0) router.push('/onboard') // if there is a single org membership, send to org home else if (organisations.length === 1) { const organisation = organisations[0] diff --git a/frontend/app/webauth/[requestCode]/page.tsx b/frontend/app/webauth/[requestCode]/page.tsx index 27188b04e..47b0a5e37 100644 --- a/frontend/app/webauth/[requestCode]/page.tsx +++ b/frontend/app/webauth/[requestCode]/page.tsx @@ -169,7 +169,7 @@ export default function WebAuth({ params }: { params: { requestCode: string } }) }, [params.requestCode]) useEffect(() => { - if (organisations?.length === 0) router.push('/signup') + if (organisations?.length === 0) router.push('/onboard') }, [organisations, router]) const OrganisationSelectPanel = (props: { diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 6e885f305..222282857 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -208,7 +208,7 @@ const Sidebar = () => {
{!isOwner && (
- + diff --git a/frontend/contexts/userContext.tsx b/frontend/contexts/userContext.tsx index 9fb2e3029..f498436b4 100644 --- a/frontend/contexts/userContext.tsx +++ b/frontend/contexts/userContext.tsx @@ -27,7 +27,7 @@ const UserContext = createContext({ refetch: async () => {}, }) -const PUBLIC_PATHS = ['/login', '/register', '/lockbox'] +const PUBLIC_PATHS = ['/login', '/signup', '/lockbox'] export function UserProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) diff --git a/frontend/middleware.ts b/frontend/middleware.ts index c16950b80..e22d7465a 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -39,6 +39,6 @@ export const config = { * - register (registration page) * - lockbox (public lockbox viewer) */ - '/((?!api|_next/static|_next/image|favicon.ico|favicon.svg|assets|login|register|lockbox|api/health).*)', + '/((?!api|_next/static|_next/image|favicon.ico|favicon.svg|assets|login|signup|register|lockbox|api/health).*)', ], } diff --git a/frontend/tests/contexts/userContext.test.tsx b/frontend/tests/contexts/userContext.test.tsx index 30dbef739..c1ed136c0 100644 --- a/frontend/tests/contexts/userContext.test.tsx +++ b/frontend/tests/contexts/userContext.test.tsx @@ -152,4 +152,26 @@ describe('UserProvider', () => { // Should stay on login, not redirect expect(window.location.href).not.toContain('/login?callbackUrl=') }) + + it('does not redirect on /signup (public path for registration)', async () => { + usePathname.mockReturnValue('/signup') + Object.defineProperty(window, 'location', { + configurable: true, + value: { + href: 'http://localhost/signup', + pathname: '/signup', + search: '', + }, + }) + + mockedAxios.get.mockRejectedValue({ response: { status: 403 } }) + + await act(async () => { + root.render(React.createElement(UserProvider, null, React.createElement(Consumer))) + }) + await flushEffects() + + // Should stay on signup, not redirect to login + expect(window.location.href).toBe('http://localhost/signup') + }) }) diff --git a/frontend/utils/navigation.ts b/frontend/utils/navigation.ts index 100cdc6d0..450879438 100644 --- a/frontend/utils/navigation.ts +++ b/frontend/utils/navigation.ts @@ -33,6 +33,9 @@ export const generateBreadcrumbs = (ctx: NavigationContext): NavigationItem[] => case 'signup': breadcrumbs.push({ label: 'Sign Up', isLink: false }) break + case 'onboard': + breadcrumbs.push({ label: 'Create Organisation', isLink: false }) + break case 'login': breadcrumbs.push({ label: 'Login', isLink: false }) break From da1247b4f8382a33149290b68611d0c056b31eb6 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 03:40:01 +0800 Subject: [PATCH 033/100] fix: use in-memory cache for tests to avoid Redis dependency in CI Override CACHES to LocMemCache in conftest.py so DRF throttle tests don't require a running Redis instance. --- backend/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/conftest.py b/backend/conftest.py index 1a6c41ac7..a8cba84c0 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -25,3 +25,12 @@ def pytest_configure(): django.setup() + + # Override cache to in-memory so tests don't require a running Redis + from django.conf import settings + + settings.CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } + } From a178f3f5d713f9977b6f9e00001c2251376ddcbd Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:16:29 +0800 Subject: [PATCH 034/100] feat: add full_name field to CustomUser model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signup form collected the user's full name but never stored it — CustomUser had no full_name field. Add the field and consolidate the EmailVerification migration into a single 0120 migration. --- ...py => 0120_add_emailverification_and_user_full_name.py} | 7 ++++++- backend/api/models.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) rename backend/api/migrations/{0120_emailverification.py => 0120_add_emailverification_and_user_full_name.py} (79%) diff --git a/backend/api/migrations/0120_emailverification.py b/backend/api/migrations/0120_add_emailverification_and_user_full_name.py similarity index 79% rename from backend/api/migrations/0120_emailverification.py rename to backend/api/migrations/0120_add_emailverification_and_user_full_name.py index 342cbdee7..19374eac9 100644 --- a/backend/api/migrations/0120_emailverification.py +++ b/backend/api/migrations/0120_add_emailverification_and_user_full_name.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.29 on 2026-04-13 16:32 +# Generated by Django 4.2.29 on 2026-04-14 06:08 from django.conf import settings from django.db import migrations, models @@ -12,6 +12,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='customuser', + name='full_name', + field=models.CharField(blank=True, default='', max_length=128), + ), migrations.CreateModel( name='EmailVerification', fields=[ diff --git a/backend/api/models.py b/backend/api/models.py index 94ba46a1a..ca2dfe7c1 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -52,6 +52,7 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): userId = models.TextField(default=uuid4, primary_key=True, editable=False) username = models.CharField(max_length=64, unique=True, null=False, blank=False) email = models.EmailField(max_length=100, unique=True, null=False, blank=False) + full_name = models.CharField(max_length=128, blank=True, default="") USERNAME_FIELD = "username" REQUIRED_FIELDS = ["email"] From 5dd216f4197fe5f3e03082a73dceb2703d5211a5 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:16:37 +0800 Subject: [PATCH 035/100] fix: store full_name during password registration The signup form sent fullName to the backend but password_register never saved it to the user. Now persists it on the CustomUser model. --- backend/api/views/auth_password.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index 6465e358c..e18bac33f 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -160,6 +160,8 @@ def password_register(request): password=auth_hash, ) user.active = skip_verification + if full_name: + user.full_name = full_name user.save() if not skip_verification: From 1f593d840d88d4aa870b5807f039db054477978c Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:16:43 +0800 Subject: [PATCH 036/100] fix: resolve full_name from user model for password-only accounts The GraphQL resolver only checked SocialAccount data, so password-only users always got null. Now falls back to user.full_name. --- backend/backend/graphene/types.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py index 286bf5567..ace4a9841 100644 --- a/backend/backend/graphene/types.py +++ b/backend/backend/graphene/types.py @@ -195,7 +195,11 @@ def resolve_username(self, info): def resolve_full_name(self, info): social_acc = self.user.socialaccount_set.first() if social_acc: - return social_acc.extra_data.get("name") + name = social_acc.extra_data.get("name") + if name: + return name + if self.user.full_name: + return self.user.full_name return None def resolve_avatar_url(self, info): From 92f2aa9af4e72e9a73a11fde9984de9206653410 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:16:48 +0800 Subject: [PATCH 037/100] fix: resolve full_name in serializer for password-only accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix as the GraphQL resolver — fall back to user.full_name when no social account name is available. --- backend/api/serializers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index ad4df6b96..9a1fc642f 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -80,7 +80,11 @@ class Meta: def get_full_name(self, obj): social_acc = obj.user.socialaccount_set.first() if social_acc: - return social_acc.extra_data.get("name") + name = social_acc.extra_data.get("name") + if name: + return name + if obj.user.full_name: + return obj.user.full_name return None def get_role(self, obj): From 3fb4dffca060cee21a999a862d15627b7c4d6db3 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:16:54 +0800 Subject: [PATCH 038/100] fix: use full_name in Slack signup notification for password users The signal handler only checked SocialAccount data for the name, falling back to username (email). Now checks user.full_name first. --- backend/api/signals.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/api/signals.py b/backend/api/signals.py index 0d39f7312..b4a28216b 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -17,6 +17,7 @@ def notify_new_user_signup(request, user, **kwargs): social_account = user.socialaccount_set.first() full_name = ( (social_account.extra_data.get("name") if social_account else None) + or user.full_name or user.username or user.email ) From 66351b0fb77f1c60118f244077fa70da3a1bb6d0 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:19:36 +0800 Subject: [PATCH 039/100] test: verify full_name is stored during registration and returned on login - Assert register endpoint saves fullName to user.full_name - Assert login returns email as fallback when full_name is empty - Add test for login returning stored full_name for password users --- backend/tests/test_auth_password.py | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py index c64666555..f0b2c6968 100644 --- a/backend/tests/test_auth_password.py +++ b/backend/tests/test_auth_password.py @@ -99,6 +99,9 @@ def test_register_creates_user( mock_ev.objects.create.assert_called_once() mock_send_email.assert_called_once() + # Verify full_name was saved on the user object + self.assertEqual(new_user.full_name, "Alice Test") + @patch("api.views.auth_password.get_user_model") def test_register_rejects_duplicate_email(self, mock_get_user): """Registration fails if email already exists.""" @@ -226,9 +229,36 @@ def test_login_succeeds_with_correct_hash(self, mock_get_user, mock_login): self.assertEqual(response.status_code, 200) data = json.loads(response.content) self.assertEqual(data["email"], "alice@example.com") + self.assertEqual(data["fullName"], "alice@example.com") # no full_name, falls back to email self.assertEqual(data["authMethod"], "password") mock_login.assert_called_once() + @patch("api.views.auth_password.login") + @patch("api.views.auth_password.get_user_model") + def test_login_returns_full_name_for_password_user(self, mock_get_user, mock_login): + """Login returns stored full_name for password-only users.""" + User = MagicMock() + user = MagicMock() + user.active = True + user.userId = "uuid-123" + user.email = "alice@example.com" + user.full_name = "Alice Test" + user.auth_method = "password" + user.check_password.return_value = True + user.socialaccount_set.first.return_value = None + User.objects.get.return_value = user + mock_get_user.return_value = User + + request = _make_post( + "/auth/password/login/", + {"email": "alice@example.com", "authHash": "a" * 64}, + ) + response = password_login(request) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertEqual(data["fullName"], "Alice Test") + @patch("api.views.auth_password.get_user_model") def test_login_fails_with_wrong_hash(self, mock_get_user): """Wrong authHash returns 401.""" From 8c0390d54e3b9ca5445270be150aec1fa49d4018 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:19:42 +0800 Subject: [PATCH 040/100] test: verify auth_me returns user.full_name when no social account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the positive case where a password-only user has full_name set and no social account — should return the stored name, not the email. --- backend/tests/test_sso_providers.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_sso_providers.py b/backend/tests/test_sso_providers.py index ef9739969..dfe386551 100644 --- a/backend/tests/test_sso_providers.py +++ b/backend/tests/test_sso_providers.py @@ -1170,7 +1170,7 @@ def test_authelia_no_avatar(self): self.assertEqual(data["fullName"], "Alice Johnson") def test_no_social_account_falls_back_to_email(self): - """User with no social account gets email as fullName.""" + """User with no social account and no full_name gets email as fullName.""" request = self.factory.get("/auth/me/") user = MagicMock() user.is_authenticated = True @@ -1187,6 +1187,23 @@ def test_no_social_account_falls_back_to_email(self): self.assertEqual(data["fullName"], "alice@test.com") self.assertIsNone(data["avatarUrl"]) + def test_no_social_account_uses_user_full_name(self): + """User with no social account but stored full_name gets it as fullName.""" + request = self.factory.get("/auth/me/") + user = MagicMock() + user.is_authenticated = True + user.userId = "user-uuid" + user.email = "alice@test.com" + user.full_name = "Alice Test" + user.auth_method = "password" + user.socialaccount_set.first.return_value = None + request.user = user + _add_session(request) + + response = auth_me(request) + data = json.loads(response.content) + self.assertEqual(data["fullName"], "Alice Test") + # ═══════════════════════════════════════════════════════════════════════════ # 7. Token exchange auth method verification From 7538bb2efaf443e53d145f9b4c31beb8b4c9fe92 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 14 Apr 2026 15:27:19 +0800 Subject: [PATCH 041/100] fix: mock _smtp_configured in tests that expect email verification CI has no SMTP configured, so password_register skips verification. Tests that assert on verification behavior now mock _smtp_configured to return True so the verification code path is exercised. --- backend/tests/test_auth_password.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py index f0b2c6968..47d6dad24 100644 --- a/backend/tests/test_auth_password.py +++ b/backend/tests/test_auth_password.py @@ -72,12 +72,13 @@ class PasswordRegisterTest(_ThrottleClearMixin, unittest.TestCase): "fullName": "Alice Test", } + @patch("api.views.auth_password._smtp_configured", return_value=True) @patch("api.views.auth_password.transaction") @patch("api.views.auth_password._send_verification_email") @patch("api.views.auth_password.EmailVerification") @patch("api.views.auth_password.get_user_model") def test_register_creates_user( - self, mock_get_user, mock_ev, mock_send_email, mock_tx + self, mock_get_user, mock_ev, mock_send_email, mock_tx, mock_smtp ): """Successful registration creates user + verification token.""" User = MagicMock() @@ -420,12 +421,13 @@ def test_change_rejects_missing_fields(self): class VerificationEmailLoggingTest(_ThrottleClearMixin, unittest.TestCase): """Ensure verification URL is always logged.""" + @patch("api.views.auth_password._smtp_configured", return_value=True) @patch("api.views.auth_password.transaction") @patch("api.views.auth_password._send_verification_email") @patch("api.views.auth_password.EmailVerification") @patch("api.views.auth_password.get_user_model") def test_verification_url_logged( - self, mock_get_user, mock_ev, mock_send_email, mock_tx + self, mock_get_user, mock_ev, mock_send_email, mock_tx, mock_smtp ): """Registration always calls _send_verification_email which logs.""" User = MagicMock() @@ -696,13 +698,14 @@ def test_register_skips_verification(self, mock_get_user, mock_ev, mock_tx, mock # No verification token should be created mock_ev.objects.create.assert_not_called() + @patch("api.views.auth_password._smtp_configured", return_value=True) @patch("api.views.auth_password._skip_email_verification", return_value=False) @patch("api.views.auth_password._send_verification_email") @patch("api.views.auth_password.transaction") @patch("api.views.auth_password.EmailVerification") @patch("api.views.auth_password.get_user_model") def test_register_requires_verification_by_default( - self, mock_get_user, mock_ev, mock_tx, mock_send_email, mock_skip + self, mock_get_user, mock_ev, mock_tx, mock_send_email, mock_skip, mock_smtp ): """Without flag, user is inactive and verification token is created.""" User = MagicMock() @@ -731,13 +734,14 @@ def test_register_requires_verification_by_default( class PasswordSignupFlowTest(_ThrottleClearMixin, unittest.TestCase): """Full password signup flow: register → verify → login.""" + @patch("api.views.auth_password._smtp_configured", return_value=True) @patch("api.views.auth_password.login") @patch("api.views.auth_password.transaction") @patch("api.views.auth_password._send_verification_email") @patch("api.views.auth_password.EmailVerification") @patch("api.views.auth_password.get_user_model") def test_full_password_signup_flow( - self, mock_get_user, mock_ev, mock_send_email, mock_tx, mock_login + self, mock_get_user, mock_ev, mock_send_email, mock_tx, mock_login, mock_smtp ): """Register → verify email → login succeeds.""" User = MagicMock() @@ -790,11 +794,12 @@ def test_full_password_signup_flow( self.assertEqual(data["authMethod"], "password") mock_login.assert_called_once() + @patch("api.views.auth_password._smtp_configured", return_value=True) @patch("api.views.auth_password.transaction") @patch("api.views.auth_password.EmailVerification") @patch("api.views.auth_password.get_user_model") def test_login_blocked_before_verification( - self, mock_get_user, mock_ev, mock_tx + self, mock_get_user, mock_ev, mock_tx, mock_smtp ): """User cannot login before verifying email.""" User = MagicMock() From 3b74d6da02045e888dcedcb6cb7c59f1f9828243 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:19:40 +0800 Subject: [PATCH 042/100] feat(backend): add org-level SSO data model and provider registry - Organisation.require_sso boolean flag - OrganisationSSOProvider model (provider_type, name, config JSON, enabled, created_by/updated_by audit trail), with a unique(org, provider_type) constraint so only one row per provider per org - Migration 0121 - Central registry in api/utils/sso.py for provider metadata: label, issuer template/field, callback slug, adapter class, required fields + shape validators, public-field allowlist - validate_provider_config() for shape checks at write time - get_public_config_fields() for the publicConfig allowlist - Default SSO permissions added to Owner/Admin roles --- ...ion_require_sso_organisationssoprovider.py | 38 ++++ backend/api/models.py | 36 ++++ backend/api/utils/access/roles.py | 5 + backend/api/utils/sso.py | 170 ++++++++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 backend/api/migrations/0121_organisation_require_sso_organisationssoprovider.py create mode 100644 backend/api/utils/sso.py diff --git a/backend/api/migrations/0121_organisation_require_sso_organisationssoprovider.py b/backend/api/migrations/0121_organisation_require_sso_organisationssoprovider.py new file mode 100644 index 000000000..bb3a60b94 --- /dev/null +++ b/backend/api/migrations/0121_organisation_require_sso_organisationssoprovider.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.29 on 2026-04-14 08:48 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0120_add_emailverification_and_user_full_name'), + ] + + operations = [ + migrations.AddField( + model_name='organisation', + name='require_sso', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='OrganisationSSOProvider', + fields=[ + ('id', models.TextField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('provider_type', models.CharField(choices=[('entra_id', 'Microsoft Entra ID'), ('okta', 'Okta')], max_length=50)), + ('name', models.CharField(max_length=128)), + ('config', models.JSONField()), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers_created', to='api.organisationmember')), + ('organisation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers', to='api.organisation')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sso_providers_updated', to='api.organisationmember')), + ], + options={ + 'unique_together': {('organisation', 'provider_type')}, + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index ca2dfe7c1..926f6d3f5 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -111,6 +111,7 @@ class Organisation(models.Model): stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) stripe_subscription_id = models.CharField(max_length=255, blank=True, null=True) pricing_version = models.IntegerField(default=1) + require_sso = models.BooleanField(default=False) list_display = ("name", "identity_key", "id") def save(self, *args, **kwargs): @@ -122,6 +123,41 @@ def __str__(self): return self.name +class OrganisationSSOProvider(models.Model): + from api.utils.sso import ORG_SSO_PROVIDER_CHOICES + + id = models.TextField(default=uuid4, primary_key=True, editable=False) + organisation = models.ForeignKey( + Organisation, related_name="sso_providers", on_delete=models.CASCADE + ) + provider_type = models.CharField(max_length=50, choices=ORG_SSO_PROVIDER_CHOICES) + name = models.CharField(max_length=128) + config = models.JSONField() + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + "OrganisationMember", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="sso_providers_created", + ) + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.ForeignKey( + "OrganisationMember", + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="sso_providers_updated", + ) + + class Meta: + unique_together = [("organisation", "provider_type")] + + def __str__(self): + return f"{self.name} ({self.organisation.name})" + + class ActivatedPhaseLicense(models.Model): id = models.TextField(primary_key=True, editable=False) customer_name = models.CharField(max_length=255) diff --git a/backend/api/utils/access/roles.py b/backend/api/utils/access/roles.py index 138d0e50d..b61f249aa 100644 --- a/backend/api/utils/access/roles.py +++ b/backend/api/utils/access/roles.py @@ -16,6 +16,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "SSO": ["create", "read", "update", "delete"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -48,6 +49,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "SSO": ["create", "read", "update", "delete"], }, "app_permissions": { "Environments": ["create", "read", "update", "delete"], @@ -79,6 +81,7 @@ "Roles": ["create", "read", "update", "delete"], "IntegrationCredentials": ["create", "read", "update", "delete"], "NetworkAccessPolicies": ["create", "read", "update", "delete"], + "SSO": [], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -114,6 +117,7 @@ "update", ], "NetworkAccessPolicies": ["read"], + "SSO": [], }, "app_permissions": { "Environments": ["read", "create", "update"], @@ -145,6 +149,7 @@ "Roles": ["read"], "IntegrationCredentials": ["read"], "NetworkAccessPolicies": ["read"], + "SSO": [], }, "app_permissions": { "Environments": ["read", "create", "update", "delete"], diff --git a/backend/api/utils/sso.py b/backend/api/utils/sso.py new file mode 100644 index 000000000..411447166 --- /dev/null +++ b/backend/api/utils/sso.py @@ -0,0 +1,170 @@ +import re +from urllib.parse import urlparse + +# Single source of truth for org-level SSO provider metadata. +# To add a new provider: add an entry here, create its adapter, done. +# `required_fields` is validated at create/update time so that direct GraphQL +# calls can't bypass the frontend form validation. `field_validators` is a map +# from field name to a callable that returns True iff the value is acceptable. + +UUID_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +def _is_uuid(value): + return isinstance(value, str) and bool(UUID_RE.match(value)) + + +def _is_https_url(value): + if not isinstance(value, str): + return False + parsed = urlparse(value) + return parsed.scheme == "https" and bool(parsed.netloc) + + +def _is_non_empty_string(value): + return isinstance(value, str) and bool(value.strip()) + + +ORG_SSO_PROVIDER_REGISTRY = { + "entra_id": { + "label": "Microsoft Entra ID", + "issuer_template": "https://login.microsoftonline.com/{tenant_id}/v2.0", + "issuer_field": None, + "callback_slug": "entra-id-oidc", + "adapter_module": "ee.authentication.sso.oidc.entraid.views", + "adapter_class": "CustomMicrosoftGraphOAuth2Adapter", + "provider_id": "microsoft", + "token_auth_method": "client_secret_post", + "required_fields": ("tenant_id", "client_id", "client_secret"), + "field_validators": { + "tenant_id": _is_uuid, + "client_id": _is_uuid, + "client_secret": _is_non_empty_string, + }, + # Allowlist of config keys safe to return via publicConfig. Any + # field not listed here is treated as a secret — if a new secret + # field is added to required_fields later, it stays hidden until + # this set is updated intentionally. + "public_fields": ("tenant_id", "client_id"), + }, + "okta": { + "label": "Okta", + "issuer_template": None, + "issuer_field": "issuer", + "callback_slug": "okta-oidc", + "adapter_module": "ee.authentication.sso.oidc.okta.views", + "adapter_class": "OktaOpenIDConnectAdapter", + "provider_id": "okta-oidc", + "token_auth_method": "client_secret_basic", + "required_fields": ("issuer", "client_id", "client_secret"), + "field_validators": { + "issuer": _is_https_url, + "client_id": _is_non_empty_string, + "client_secret": _is_non_empty_string, + }, + "public_fields": ("issuer", "client_id"), + }, +} + + +def get_public_config_fields(provider_type): + """Allowlist of config keys exposed via publicConfig for a provider.""" + meta = get_org_provider_meta(provider_type) + if not meta: + return () + return meta.get("public_fields", ()) + +# Django model choices derived from the registry +ORG_SSO_PROVIDER_CHOICES = [(key, meta["label"]) for key, meta in ORG_SSO_PROVIDER_REGISTRY.items()] + + +def get_org_provider_meta(provider_type): + """Look up provider metadata from the registry. Returns None if unknown.""" + return ORG_SSO_PROVIDER_REGISTRY.get(provider_type) + + +def resolve_issuer(provider_type, config): + """Build the OIDC issuer URL from provider metadata + config.""" + meta = get_org_provider_meta(provider_type) + if not meta: + return None + if meta["issuer_template"]: + return meta["issuer_template"].format(**config) + if meta["issuer_field"]: + return config.get(meta["issuer_field"]) + return None + + +SEALED_SECRET_PREFIX = "ph:v1:" + + +def is_sealed_secret(value): + """True if the value looks like a server-sealed secret. + + Client-side code encrypts client_secret with encryptAsymmetric before + sending; the resulting string has the form ph:v1:: + (4 colon-separated segments). A plaintext secret would miss this prefix + and the first SSO auth request would fail at decrypt. Reject at write + time so the failure is surfaced to the admin instead of to every user + trying to log in. + """ + if not isinstance(value, str): + return False + if not value.startswith(SEALED_SECRET_PREFIX): + return False + return len(value.split(":")) == 4 + + +def validate_provider_config(provider_type, config, require_secret=True): + """Validate a provider config dict against the registry. Raises ValueError. + + - Every required_fields entry must be present and pass its validator. + - client_secret must be a sealed ciphertext (ph:v1:...). + - require_secret=False skips the secret check (used on update, where + blank means "keep existing" — the caller filters it out upstream). + """ + meta = get_org_provider_meta(provider_type) + if not meta: + raise ValueError(f"Unsupported provider type: {provider_type}") + if not isinstance(config, dict): + raise ValueError("config must be an object") + + required = meta.get("required_fields", ()) + validators = meta.get("field_validators", {}) + + for field in required: + if field == "client_secret" and not require_secret: + continue + value = config.get(field) + if value is None or value == "": + raise ValueError(f"Missing required field: {field}") + validator = validators.get(field) + if validator and not validator(value): + raise ValueError(f"Invalid value for field: {field}") + + secret = config.get("client_secret") + if secret and not is_sealed_secret(secret): + raise ValueError( + "client_secret must be encrypted client-side before submission" + ) + + +def get_org_sso_config(config_id): + """Load an org SSO config from DB and decrypt client_secret. + + Returns (provider_instance, decrypted_config_dict). + """ + from api.models import OrganisationSSOProvider + from api.utils.crypto import get_server_keypair, decrypt_asymmetric + + provider = OrganisationSSOProvider.objects.get(id=config_id, enabled=True) + pk, sk = get_server_keypair() + + config = provider.config.copy() + config["client_secret"] = decrypt_asymmetric( + config["client_secret"], sk.hex(), pk.hex() + ) + return provider, config From 7b55063f3d89f7afc1b697bd32e5234d7bea709d Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:19:53 +0800 Subject: [PATCH 043/100] feat(backend): expose org SSO providers and enforcement via GraphQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrganisationSSOProviderType with provider_type, name, enabled, audit fields, and a publicConfig resolver that returns only keys listed in the registry's public_fields allowlist - sso_providers list on OrganisationType, gated by SSO:read permission - require_sso exposed on OrganisationType - Five new mutations: * CreateOrganisationSSOProviderMutation — entitlement + config validation, enforces unique (org, provider_type) * UpdateOrganisationSSOProviderMutation — partial updates, merges client_secret (blank = keep existing), re-validates the merged config, disables enforcement when the last enabled provider is deactivated * DeleteOrganisationSSOProviderMutation — clears enforcement when the active provider is deleted * TestOrganisationSSOProviderMutation — OIDC discovery smoke test * UpdateOrganisationSecurityMutation — flips require_sso; when enabling from a non-SSO admin session, logs that session out and returns session_invalidated so the UI can redirect --- backend/backend/graphene/mutations/sso.py | 301 ++++++++++++++++++++++ backend/backend/graphene/types.py | 43 ++++ backend/backend/schema.py | 15 ++ 3 files changed, 359 insertions(+) create mode 100644 backend/backend/graphene/mutations/sso.py diff --git a/backend/backend/graphene/mutations/sso.py b/backend/backend/graphene/mutations/sso.py new file mode 100644 index 000000000..79f17922b --- /dev/null +++ b/backend/backend/graphene/mutations/sso.py @@ -0,0 +1,301 @@ +from django.conf import settings +from django.contrib.auth import logout as django_logout +from django.utils import timezone + +from api.models import ( + Organisation, + OrganisationMember, + OrganisationSSOProvider, +) +from api.utils.access.permissions import user_has_permission +from api.utils.network import validate_url_is_safe +from api.utils.sso import ( + ORG_SSO_PROVIDER_REGISTRY, + get_org_provider_meta, + resolve_issuer, + validate_provider_config, +) +from django.core.exceptions import ValidationError +import graphene +import logging +from graphql import GraphQLError + +logger = logging.getLogger(__name__) + +CLOUD_HOSTED = settings.APP_HOST == "cloud" + + +def _check_sso_entitlement(org): + """Verify the org is entitled to use SSO. + + Cloud: org must be on the Enterprise plan. + Self-hosted: requires an active ActivatedPhaseLicense (checked at adapter level). + """ + if CLOUD_HOSTED and org.plan != Organisation.ENTERPRISE_PLAN: + raise GraphQLError( + "SSO is available on the Enterprise plan. Please upgrade to configure SSO." + ) + + +class CreateOrganisationSSOProviderMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + provider_type = graphene.String(required=True) + name = graphene.String(required=True) + config = graphene.JSONString(required=True) + + provider_id = graphene.ID() + + @classmethod + def mutate(cls, root, info, org_id, provider_type, name, config): + user = info.context.user + org = Organisation.objects.get(id=org_id) + + if not user_has_permission(user, "create", "SSO", org): + raise GraphQLError( + "You don't have the permissions required to configure SSO in this organisation" + ) + + _check_sso_entitlement(org) + + meta = get_org_provider_meta(provider_type) + if not meta: + raise GraphQLError(f"Unsupported provider type: {provider_type}") + + if OrganisationSSOProvider.objects.filter( + organisation=org, provider_type=provider_type + ).exists(): + raise GraphQLError( + f"An SSO provider of type '{meta['label']}' is already configured for this organisation" + ) + + try: + validate_provider_config(provider_type, config, require_secret=True) + except ValueError as e: + raise GraphQLError(str(e)) + + member = OrganisationMember.objects.get( + user=user, organisation=org, deleted_at=None + ) + + provider = OrganisationSSOProvider.objects.create( + organisation=org, + provider_type=provider_type, + name=name, + config=config, + enabled=False, + created_by=member, + updated_by=member, + ) + + return CreateOrganisationSSOProviderMutation(provider_id=provider.id) + + +class UpdateOrganisationSSOProviderMutation(graphene.Mutation): + class Arguments: + provider_id = graphene.ID(required=True) + name = graphene.String() + config = graphene.JSONString() + enabled = graphene.Boolean() + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, provider_id, name=None, config=None, enabled=None): + user = info.context.user + provider = OrganisationSSOProvider.objects.get(id=provider_id) + + if not user_has_permission(user, "update", "SSO", provider.organisation): + raise GraphQLError( + "You don't have the permissions required to update SSO in this organisation" + ) + + _check_sso_entitlement(provider.organisation) + + member = OrganisationMember.objects.get( + user=user, organisation=provider.organisation, deleted_at=None + ) + + if name is not None: + provider.name = name + + if config is not None: + existing_config = provider.config.copy() + secret_was_provided = False + for key, value in config.items(): + if key == "client_secret" and not value: + continue + if key == "client_secret": + secret_was_provided = True + existing_config[key] = value + try: + validate_provider_config( + provider.provider_type, + existing_config, + require_secret=secret_was_provided, + ) + except ValueError as e: + raise GraphQLError(str(e)) + provider.config = existing_config + + if enabled is not None: + if enabled: + # Enabling this provider — deactivate all others in the org + OrganisationSSOProvider.objects.filter( + organisation=provider.organisation, enabled=True + ).exclude(id=provider_id).update(enabled=False) + elif provider.enabled and provider.organisation.require_sso: + # Deactivating the currently-active provider while SSO is + # enforced would lock everyone (including this admin) out + # on their next request, since no provider would be able + # to authenticate them. Mirror the delete-mutation policy: + # turn enforcement off when the last active provider goes + # inactive. The admin can re-enforce after (re-)activating. + still_has_active = ( + OrganisationSSOProvider.objects.filter( + organisation=provider.organisation, enabled=True + ) + .exclude(id=provider_id) + .exists() + ) + if not still_has_active: + provider.organisation.require_sso = False + provider.organisation.save() + provider.enabled = enabled + + provider.updated_by = member + provider.save() + + return UpdateOrganisationSSOProviderMutation(ok=True) + + +class DeleteOrganisationSSOProviderMutation(graphene.Mutation): + class Arguments: + provider_id = graphene.ID(required=True) + + ok = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, provider_id): + user = info.context.user + provider = OrganisationSSOProvider.objects.get(id=provider_id) + + if not user_has_permission(user, "delete", "SSO", provider.organisation): + raise GraphQLError( + "You don't have the permissions required to delete SSO in this organisation" + ) + + # If this was the active provider and SSO was enforced, turn off enforcement + if provider.enabled and provider.organisation.require_sso: + provider.organisation.require_sso = False + provider.organisation.save() + + provider.delete() + + return DeleteOrganisationSSOProviderMutation(ok=True) + + +class TestOrganisationSSOProviderMutation(graphene.Mutation): + class Arguments: + provider_id = graphene.ID(required=True) + + success = graphene.Boolean() + error = graphene.String() + + @classmethod + def mutate(cls, root, info, provider_id): + import requests as http_requests + + user = info.context.user + provider = OrganisationSSOProvider.objects.get(id=provider_id) + + if not user_has_permission(user, "update", "SSO", provider.organisation): + raise GraphQLError( + "You don't have the permissions required to test SSO in this organisation" + ) + + # Build OIDC discovery URL from config + issuer = resolve_issuer(provider.provider_type, provider.config) + if not issuer: + return TestOrganisationSSOProviderMutation( + success=False, error="Unsupported provider type" + ) + + if CLOUD_HOSTED: + try: + validate_url_is_safe(issuer) + except ValidationError: + return TestOrganisationSSOProviderMutation( + success=False, + error="Issuer URL is not a valid public HTTPS endpoint", + ) + + discovery_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" + try: + resp = http_requests.get(discovery_url, timeout=10) + resp.raise_for_status() + data = resp.json() + if "authorization_endpoint" not in data or "token_endpoint" not in data: + return TestOrganisationSSOProviderMutation( + success=False, + error="OIDC discovery document is missing required endpoints", + ) + return TestOrganisationSSOProviderMutation(success=True, error=None) + except Exception as e: + # Don't surface upstream response bodies or internal error + # detail to the client — that would leak info from whatever + # host the (possibly-malicious) issuer pointed at. Log the + # real error server-side, return a generic message. + logger.warning( + f"OIDC discovery failed for provider {provider_id}: {e}" + ) + return TestOrganisationSSOProviderMutation( + success=False, + error="Failed to reach the OIDC provider. Check the issuer URL and try again.", + ) + + +class UpdateOrganisationSecurityMutation(graphene.Mutation): + class Arguments: + org_id = graphene.ID(required=True) + require_sso = graphene.Boolean(required=True) + + ok = graphene.Boolean() + session_invalidated = graphene.Boolean() + + @classmethod + def mutate(cls, root, info, org_id, require_sso): + user = info.context.user + org = Organisation.objects.get(id=org_id) + + if not user_has_permission(user, "update", "SSO", org): + raise GraphQLError( + "You don't have the permissions required to update SSO settings" + ) + + if require_sso: + # Must have at least one enabled SSO provider + if not OrganisationSSOProvider.objects.filter( + organisation=org, enabled=True + ).exists(): + raise GraphQLError( + "Cannot enforce SSO without an active SSO provider" + ) + + org.require_sso = require_sso + org.save() + + # When enabling enforcement, immediately invalidate the admin's own + # session so they are forced to re-authenticate via SSO. This is a + # clean break — no half-state where this session keeps working on + # the page it's on but fails on the next navigation. Other users' + # sessions are invalidated passively by OrgSSOEnforcementMiddleware + # on their next org-scoped query. + session_invalidated = False + if require_sso and info.context.session.get("auth_method") != "sso": + django_logout(info.context) + session_invalidated = True + + return UpdateOrganisationSecurityMutation( + ok=True, session_invalidated=session_invalidated + ) diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py index ace4a9841..ef4580601 100644 --- a/backend/backend/graphene/types.py +++ b/backend/backend/graphene/types.py @@ -23,6 +23,7 @@ Lockbox, NetworkAccessPolicy, Organisation, + OrganisationSSOProvider, App, OrganisationMember, OrganisationMemberInvite, @@ -85,12 +86,48 @@ def resolve_description(self, info): return self.description +class OrganisationSSOProviderType(DjangoObjectType): + public_config = graphene.JSONString() + + class Meta: + model = OrganisationSSOProvider + fields = ( + "id", + "provider_type", + "name", + "enabled", + "created_at", + "updated_at", + ) + + created_by = graphene.Field(lambda: OrganisationMemberType) + updated_by = graphene.Field(lambda: OrganisationMemberType) + + def resolve_public_config(self, info): + """Return only fields that are explicitly marked public in the + provider registry. Using an allowlist (not a denylist) means new + secret-bearing fields added to a provider stay hidden by default + until a registry change opts them in. + """ + from api.utils.sso import get_public_config_fields + + allowed = set(get_public_config_fields(self.provider_type)) + return {k: v for k, v in (self.config or {}).items() if k in allowed} + + def resolve_created_by(self, info): + return self.created_by + + def resolve_updated_by(self, info): + return self.updated_by + + class OrganisationType(DjangoObjectType): role = graphene.Field(RoleType) member_id = graphene.ID() keyring = graphene.String() recovery = graphene.String() plan_detail = graphene.Field(OrganisationPlanType) + sso_providers = graphene.List(OrganisationSSOProviderType) class Meta: model = Organisation @@ -105,8 +142,14 @@ class Meta: "keyring", "recovery", "pricing_version", + "require_sso", ) + def resolve_sso_providers(self, info): + if not user_has_permission(info.context.user, "read", "SSO", self): + return [] + return self.sso_providers.all() + @staticmethod def _get_member(org, info): if not hasattr(org, "_cached_member"): diff --git a/backend/backend/schema.py b/backend/backend/schema.py index 280476c4b..1dedbe4bc 100644 --- a/backend/backend/schema.py +++ b/backend/backend/schema.py @@ -52,6 +52,13 @@ UpdateCustomRoleMutation, UpdateNetworkAccessPolicyMutation, ) +from .graphene.mutations.sso import ( + CreateOrganisationSSOProviderMutation, + UpdateOrganisationSSOProviderMutation, + DeleteOrganisationSSOProviderMutation, + TestOrganisationSSOProviderMutation, + UpdateOrganisationSecurityMutation, +) from ee.billing.graphene.queries.stripe import ( StripeCheckoutDetails, StripeSubscriptionDetails, @@ -199,6 +206,7 @@ OrganisationMemberInviteType, OrganisationMemberType, OrganisationPlanType, + OrganisationSSOProviderType, OrganisationType, PhaseLicenseType, ProviderCredentialsType, @@ -1090,6 +1098,13 @@ class Mutation(graphene.ObjectType): update_identity = UpdateIdentityMutation.Field() delete_identity = DeleteIdentityMutation.Field() + # SSO + create_organisation_sso_provider = CreateOrganisationSSOProviderMutation.Field() + update_organisation_sso_provider = UpdateOrganisationSSOProviderMutation.Field() + delete_organisation_sso_provider = DeleteOrganisationSSOProviderMutation.Field() + test_organisation_sso_provider = TestOrganisationSSOProviderMutation.Field() + update_organisation_security = UpdateOrganisationSecurityMutation.Field() + # Service Accounts create_service_account = CreateServiceAccountMutation.Field() enable_service_account_server_side_key_management = ( From 9a481df6b90c1166ebf95ee9d2cf9fabfb577708 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:20:06 +0800 Subject: [PATCH 044/100] feat(backend): add org-level SSO authorize view + session markers - OrgSSOAuthorizeView at /auth/sso/org//authorize/ loads the org's configured provider from the DB, decrypts client_secret, discovers OIDC endpoints, and redirects to the IdP - SSO callback tags the session with auth_method="sso", auth_sso_org_id, and auth_sso_provider_id when completing an org-level flow - auth_me returns authSsoOrgId so the client knows the session's SSO binding - callback URL query param read accepts both callback_url and callbackUrl (djangorestframework_camel_case rewrites incoming camelCase params to snake_case, so the prior single-form read was silently dropping the return-to path) - Post-login redirect accepts only same-origin relative paths - New URL route registered ahead of the instance-level catch-all --- backend/api/views/sso.py | 128 +++++++++++++++++++++++++++++++++++++-- backend/backend/urls.py | 2 + 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/backend/api/views/sso.py b/backend/api/views/sso.py index c711c0c77..ae4788e99 100644 --- a/backend/api/views/sso.py +++ b/backend/api/views/sso.py @@ -18,6 +18,7 @@ from rest_framework.throttling import AnonRateThrottle from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken, SocialLogin +from api.models import OrganisationSSOProvider logger = logging.getLogger(__name__) @@ -401,17 +402,102 @@ def auth_me(request): if not full_name and hasattr(user, "full_name") and user.full_name: full_name = user.full_name + # Auth method from session (set at login time) + auth_method = request.session.get("auth_method", "sso") + auth_sso_org_id = request.session.get("auth_sso_org_id") + return JsonResponse( { "userId": str(user.userId), "email": user.email, "fullName": full_name or user.email, "avatarUrl": avatar_url, - "authMethod": getattr(user, "auth_method", "sso"), + "authMethod": auth_method, + "authSsoOrgId": auth_sso_org_id, } ) +# --- Org-level SSO Authorize --- + +class OrgSSOAuthorizeView(View): + """ + GET /auth/sso/org//authorize/ + + Loads SSO config from DB for the given org provider, builds the + OIDC authorization URL, and redirects the user to the IdP. + """ + + def get(self, request, config_id): + from api.utils.sso import get_org_sso_config + + try: + org_provider, config = get_org_sso_config(config_id) + except Exception: + return JsonResponse( + {"error": "SSO provider not found or not enabled."}, + status=404, + ) + + # Build issuer + callback from provider registry + from api.utils.sso import get_org_provider_meta, resolve_issuer + + meta = get_org_provider_meta(org_provider.provider_type) + if not meta: + return JsonResponse({"error": "Unsupported provider type."}, status=400) + + issuer = resolve_issuer(org_provider.provider_type, config) + if not issuer: + return JsonResponse({"error": "Could not determine OIDC issuer."}, status=400) + + endpoints = _get_oidc_endpoints(issuer) + if not endpoints: + return JsonResponse( + {"error": "Failed to discover OIDC endpoints. Please check OIDC configuration."}, + status=502, + ) + + callback_url = _get_callback_url(meta["callback_slug"]) + + state = secrets.token_urlsafe(32) + nonce = secrets.token_urlsafe(32) + + request.session["sso_state"] = state + request.session["sso_provider"] = meta["callback_slug"] + request.session["sso_callback_url"] = callback_url + request.session["sso_token_url"] = endpoints["token_url"] + request.session["sso_nonce"] = nonce + # Mark this as org-level SSO so the callback loads config from DB + request.session["sso_org_config_id"] = str(org_provider.id) + + # djangorestframework_camel_case.CamelCaseMiddleWare rewrites + # incoming query params from camelCase to snake_case, so the + # frontend's ?callbackUrl= arrives here as 'callback_url'. Read both + # for safety. + callback_url_param = request.GET.get("callback_url") or request.GET.get("callbackUrl") + if callback_url_param: + request.session["sso_return_to"] = callback_url_param + + request.session.save() + + params = { + "client_id": config["client_id"], + "redirect_uri": callback_url, + "scope": "openid profile email", + "state": state, + "response_type": "code", + "nonce": nonce, + } + + authorize_url = endpoints["authorize_url"] + parsed = urlparse(authorize_url) + if not parsed.scheme == "https" or not parsed.netloc: + return JsonResponse({"error": "Invalid authorize URL"}, status=500) + + full_url = f"{authorize_url}?{urlencode(params)}" + return redirect(full_url) + + # --- SSO Authorize --- class SSOAuthorizeView(View): @@ -452,7 +538,7 @@ def get(self, request, provider): # Preserve the original deep link so the user lands on the page # they requested after SSO completes (e.g. /team/settings) - callback_url_param = request.GET.get("callbackUrl") + callback_url_param = request.GET.get("callback_url") or request.GET.get("callbackUrl") if callback_url_param: request.session["sso_return_to"] = callback_url_param @@ -514,10 +600,29 @@ def get(self, request, provider): if not expected_state or state != expected_state: return redirect(f"{FRONTEND_URL}/login?error=invalid_state") - if provider not in SSO_PROVIDER_REGISTRY: + # Check if this is an org-level SSO callback + org_config_id = request.session.get("sso_org_config_id") + if org_config_id: + from api.utils.sso import get_org_sso_config + + try: + org_provider, org_config = get_org_sso_config(org_config_id) + except Exception: + return redirect(f"{FRONTEND_URL}/login?error=sso_config_not_found") + + from api.utils.sso import get_org_provider_meta + + adapter_info = get_org_provider_meta(org_provider.provider_type) + if not adapter_info: + return redirect(f"{FRONTEND_URL}/login?error=unsupported_provider") + + config = {**org_config, **adapter_info, "is_oidc": True} + + elif provider not in SSO_PROVIDER_REGISTRY: return redirect(f"{FRONTEND_URL}/login?error=unknown_provider") + else: + config = SSO_PROVIDER_REGISTRY[provider] - config = SSO_PROVIDER_REGISTRY[provider] callback_url = request.session.get("sso_callback_url", _get_callback_url(provider)) token_url = request.session.get("sso_token_url", config.get("token_url", "")) @@ -587,12 +692,23 @@ def get(self, request, provider): logger.warning(f"SSO login failed to authenticate user for {provider}") return redirect(f"{FRONTEND_URL}/login?error=login_failed") + # Tag session with auth method + request.session["auth_method"] = "sso" + if org_config_id: + # Resolve the SSO provider config to its org ID + try: + sso_provider_obj = OrganisationSSOProvider.objects.get(id=org_config_id) + request.session["auth_sso_org_id"] = str(sso_provider_obj.organisation_id) + request.session["auth_sso_provider_id"] = str(sso_provider_obj.id) + except OrganisationSSOProvider.DoesNotExist: + pass + # Restore the original deep link, then clean up SSO session data return_to = request.session.pop("sso_return_to", None) - for key in ["sso_state", "sso_provider", "sso_callback_url", "sso_token_url", "sso_nonce"]: + for key in ["sso_state", "sso_provider", "sso_callback_url", "sso_token_url", "sso_nonce", "sso_org_config_id"]: request.session.pop(key, None) - if return_to and return_to.startswith("/"): + if return_to and return_to.startswith("/") and not return_to.startswith("//"): return redirect(FRONTEND_URL + return_to) return redirect(FRONTEND_URL + "/") diff --git a/backend/backend/urls.py b/backend/backend/urls.py index b85d25b10..d014fb0e8 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -14,6 +14,7 @@ ) from api.views.sso import ( auth_me, + OrgSSOAuthorizeView, SSOAuthorizeView, SSOCallbackView, ) @@ -43,6 +44,7 @@ path("logout/", csrf_exempt(logout_view)), # Auth endpoints path("auth/me/", auth_me), + path("auth/sso/org//authorize/", OrgSSOAuthorizeView.as_view()), path("auth/sso//authorize/", SSOAuthorizeView.as_view()), path("auth/sso//callback/", SSOCallbackView.as_view()), # Password auth From 25c015afe922ab34ab4a20691123b2548a7caf60 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:20:20 +0800 Subject: [PATCH 045/100] feat(backend): run EE OIDC adapters on cloud + add login email coverage Entra/Okta/Google-OIDC/JumpCloud-OIDC adapters previously refused to run when APP_HOST=cloud. Org-level SSO on cloud reuses the same adapters via the new /auth/sso/org//authorize/ path, so the cloud-mode refusal is replaced by a license check that runs only on self-hosted. Okta/Google-OIDC/JumpCloud-OIDC adapters now also call send_login_email on completion, aligning with Entra/GitHub/Google instance-level adapters which already notified users of new sign-ins. --- .../authentication/sso/oidc/entraid/views.py | 23 ++++++-------- .../ee/authentication/sso/oidc/okta/views.py | 30 +++++++++++-------- .../sso/oidc/util/google/views.py | 30 +++++++++++-------- .../sso/oidc/util/jumpcloud/views.py | 30 +++++++++++-------- 4 files changed, 60 insertions(+), 53 deletions(-) diff --git a/backend/ee/authentication/sso/oidc/entraid/views.py b/backend/ee/authentication/sso/oidc/entraid/views.py index d0f78befa..e4309fc7c 100644 --- a/backend/ee/authentication/sso/oidc/entraid/views.py +++ b/backend/ee/authentication/sso/oidc/entraid/views.py @@ -35,20 +35,15 @@ def _check_microsoft_errors(self, response): def complete_login(self, request, app, token, **kwargs): - if settings.APP_HOST == "cloud": - error = "OIDC is not available in cloud mode" - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) - - # Check for a valid license - activated_license_exists = ActivatedPhaseLicense.objects.filter( - expires_at__gte=timezone.now() - ).exists() - - if not activated_license_exists and not settings.PHASE_LICENSE: - error = "You need a license to login via OIDC." - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if settings.APP_HOST != "cloud": + activated_license_exists = ActivatedPhaseLicense.objects.filter( + expires_at__gte=timezone.now() + ).exists() + + if not activated_license_exists and not settings.PHASE_LICENSE: + error = "You need a license to login via OIDC." + logger.error(f"OIDC login failed: {str(error)}") + raise OAuth2Error(str(error)) headers = {"Authorization": "Bearer {0}".format(token.token)} response = ( diff --git a/backend/ee/authentication/sso/oidc/okta/views.py b/backend/ee/authentication/sso/oidc/okta/views.py index c4aead7f9..4cf10c97f 100644 --- a/backend/ee/authentication/sso/oidc/okta/views.py +++ b/backend/ee/authentication/sso/oidc/okta/views.py @@ -11,6 +11,7 @@ import logging from api.authentication.adapters.generic.provider import GenericOpenIDConnectProvider from api.authentication.adapters.generic.views import GenericOpenIDConnectAdapter +from api.emails import send_login_email from api.models import ActivatedPhaseLicense import os @@ -46,20 +47,15 @@ def default_config(self): } def complete_login(self, request, app, token, **kwargs): - if settings.APP_HOST == "cloud": - error = "OIDC is not available in cloud mode" - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if settings.APP_HOST != "cloud": + activated_license_exists = ActivatedPhaseLicense.objects.filter( + expires_at__gte=timezone.now() + ).exists() - # Check for a valid license - activated_license_exists = ActivatedPhaseLicense.objects.filter( - expires_at__gte=timezone.now() - ).exists() - - if not activated_license_exists and not settings.PHASE_LICENSE: - error = "You need a license to login via OIDC." - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if not activated_license_exists and not settings.PHASE_LICENSE: + error = "You need a license to login via OIDC." + logger.error(f"OIDC login failed: {str(error)}") + raise OAuth2Error(str(error)) try: id_token = getattr(token, "id_token", None) @@ -74,6 +70,14 @@ def complete_login(self, request, app, token, **kwargs): # Create social login object without creating user login = self.get_provider().sociallogin_from_response(request, extra_data) + try: + email = login.user.email if login.user else extra_data.get("email", "") + full_name = extra_data.get("name", "") + if email: + send_login_email(request, email, full_name, "Okta") + except Exception as email_err: + logger.error(f"Failed to send Okta login email: {email_err}") + return login except Exception as e: diff --git a/backend/ee/authentication/sso/oidc/util/google/views.py b/backend/ee/authentication/sso/oidc/util/google/views.py index f2ff8ebda..d1e297628 100644 --- a/backend/ee/authentication/sso/oidc/util/google/views.py +++ b/backend/ee/authentication/sso/oidc/util/google/views.py @@ -6,6 +6,7 @@ from allauth.socialaccount.providers.oauth2.views import OAuth2Error from api.authentication.adapters.generic.provider import GenericOpenIDConnectProvider from api.authentication.adapters.generic.views import GenericOpenIDConnectAdapter +from api.emails import send_login_email from django.conf import settings from django.utils import timezone import logging @@ -31,20 +32,15 @@ class GoogleOpenIDConnectAdapter(GenericOpenIDConnectAdapter): } def complete_login(self, request, app, token, **kwargs): - if settings.APP_HOST == "cloud": - error = "OIDC is not available in cloud mode" - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if settings.APP_HOST != "cloud": + activated_license_exists = ActivatedPhaseLicense.objects.filter( + expires_at__gte=timezone.now() + ).exists() - # Check for a valid license - activated_license_exists = ActivatedPhaseLicense.objects.filter( - expires_at__gte=timezone.now() - ).exists() - - if not activated_license_exists and not settings.PHASE_LICENSE: - error = "You need a license to login via OIDC." - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if not activated_license_exists and not settings.PHASE_LICENSE: + error = "You need a license to login via OIDC." + logger.error(f"OIDC login failed: {str(error)}") + raise OAuth2Error(str(error)) try: id_token = getattr(token, "id_token", None) @@ -59,6 +55,14 @@ def complete_login(self, request, app, token, **kwargs): # Create social login object without creating user login = self.get_provider().sociallogin_from_response(request, extra_data) + try: + email = login.user.email if login.user else extra_data.get("email", "") + full_name = extra_data.get("name", "") + if email: + send_login_email(request, email, full_name, "Google OIDC") + except Exception as email_err: + logger.error(f"Failed to send Google OIDC login email: {email_err}") + return login except Exception as e: diff --git a/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py b/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py index 155d56d65..c52e122de 100644 --- a/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py +++ b/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py @@ -11,6 +11,7 @@ import logging from api.authentication.adapters.generic.provider import GenericOpenIDConnectProvider from api.authentication.adapters.generic.views import GenericOpenIDConnectAdapter +from api.emails import send_login_email from api.models import ActivatedPhaseLicense logger = logging.getLogger(__name__) @@ -33,20 +34,15 @@ class JumpCloudOpenIDConnectAdapter(GenericOpenIDConnectAdapter): } def complete_login(self, request, app, token, **kwargs): - if settings.APP_HOST == "cloud": - error = "OIDC is not available in cloud mode" - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if settings.APP_HOST != "cloud": + activated_license_exists = ActivatedPhaseLicense.objects.filter( + expires_at__gte=timezone.now() + ).exists() - # Check for a valid license - activated_license_exists = ActivatedPhaseLicense.objects.filter( - expires_at__gte=timezone.now() - ).exists() - - if not activated_license_exists and not settings.PHASE_LICENSE: - error = "You need a license to login via OIDC." - logger.error(f"OIDC login failed: {str(error)}") - raise OAuth2Error(str(error)) + if not activated_license_exists and not settings.PHASE_LICENSE: + error = "You need a license to login via OIDC." + logger.error(f"OIDC login failed: {str(error)}") + raise OAuth2Error(str(error)) try: id_token = getattr(token, "id_token", None) @@ -61,6 +57,14 @@ def complete_login(self, request, app, token, **kwargs): # Create social login object without creating user login = self.get_provider().sociallogin_from_response(request, extra_data) + try: + email = login.user.email if login.user else extra_data.get("email", "") + full_name = extra_data.get("name", "") + if email: + send_login_email(request, email, full_name, "JumpCloud") + except Exception as email_err: + logger.error(f"Failed to send JumpCloud login email: {email_err}") + return login except Exception as e: From f70795c9d01d313039af386f04ea2f7165b7fbbe Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:20:36 +0800 Subject: [PATCH 046/100] feat(backend): return org SSO providers from email_check + preserve session markers on password change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit email_check now returns authMethods = {password, sso: [...]} — a list of org-level SSO providers the user can sign in with (joined in one query), along with whether each is enforced. Response shape migrated from {authMethod, ssoProvider}; existing tests updated. password_change and password_reset_via_recovery snapshot auth_method, auth_sso_org_id, and auth_sso_provider_id across Django's auth.login() call. set_password() invalidates the session's HASH_SESSION_KEY, which makes login() flush the session; without the save/restore, the SSO session markers would be wiped and the user would be dropped off their SSO session on the next request. password_login also sends a login notification email, matching the pattern already used by SSO adapters. --- backend/api/views/auth_password.py | 100 ++++++++++++++++++++-------- backend/tests/test_auth_password.py | 88 ++++++++++++++---------- 2 files changed, 125 insertions(+), 63 deletions(-) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index e18bac33f..27dd7e381 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -1,15 +1,3 @@ -"""Password-based authentication endpoints. - -Implements the client-side double-derivation protocol: - password + email → Argon2id → masterKey (stays on client, encrypts keyring) - ↓ - BLAKE2b-256 → authHash (sent to server) - ↓ - Django set_password(authHash) → Argon2id stored - -The server never sees the plaintext password or the masterKey. -""" - import json import logging import os @@ -51,6 +39,7 @@ def enforce_csrf(self, request): EmailVerification, Organisation, OrganisationMember, + OrganisationSSOProvider, ) logger = logging.getLogger(__name__) @@ -282,6 +271,9 @@ def password_login(request): return JsonResponse({"error": "Invalid email or password."}, status=401) login(request, user) + request.session["auth_method"] = "password" + request.session.pop("auth_sso_org_id", None) + request.session.pop("auth_sso_provider_id", None) social_acc = user.socialaccount_set.first() avatar_url = None @@ -298,6 +290,12 @@ def password_login(request): if not full_name and hasattr(user, "full_name") and user.full_name: full_name = user.full_name + try: + from api.emails import send_login_email + send_login_email(request, user.email, full_name or user.email, "Password") + except Exception as email_err: + logger.error(f"Failed to send password login email: {email_err}") + return JsonResponse( { "userId": str(user.userId), @@ -361,8 +359,16 @@ def password_change(request): wrapped_recovery=wrapped_recovery or "", ) - # Re-login so the session hash stays valid after password change + # Re-login so the session hash stays valid after password change. + prev_auth_method = request.session.get("auth_method", "password") + prev_sso_org_id = request.session.get("auth_sso_org_id") + prev_sso_provider_id = request.session.get("auth_sso_provider_id") login(request, user) + request.session["auth_method"] = prev_auth_method + if prev_sso_org_id: + request.session["auth_sso_org_id"] = prev_sso_org_id + if prev_sso_provider_id: + request.session["auth_sso_provider_id"] = prev_sso_provider_id return JsonResponse({"message": "Password changed successfully."}) @@ -424,7 +430,15 @@ def password_reset_via_recovery(request): ) # Re-login so the session hash stays valid + prev_auth_method = request.session.get("auth_method", "password") + prev_sso_org_id = request.session.get("auth_sso_org_id") + prev_sso_provider_id = request.session.get("auth_sso_provider_id") login(request, user) + request.session["auth_method"] = prev_auth_method + if prev_sso_org_id: + request.session["auth_sso_org_id"] = prev_sso_org_id + if prev_sso_provider_id: + request.session["auth_sso_provider_id"] = prev_sso_provider_id return JsonResponse({"message": "Password reset successfully."}) @@ -435,14 +449,18 @@ def password_reset_via_recovery(request): @permission_classes([AllowAny]) @throttle_classes([EmailCheckThrottle]) def email_check(request): - """Resolve the auth method for a given email. - - Returns which authentication flow the frontend should present: - - "credentials": show password field (user exists with password, OR unknown email) - - "sso": redirect to their SSO provider - - Deliberately does NOT distinguish "unknown email" from "password user" - to prevent email enumeration. + """Return all available auth methods for a given email. + + Response shape: + { + "authMethods": { + "password": true/false, + "sso": [ + {"id": "config-uuid", "providerType": "oidc", "enforced": false}, + ... + ] + } + } """ User = get_user_model() @@ -450,16 +468,42 @@ def email_check(request): if not email: return JsonResponse({"error": "Email is required."}, status=400) + # Default: password user (minimal enumeration) + default_response = { + "authMethods": { + "password": True, + "sso": [], + } + } + try: user = User.objects.get(email=email) except User.DoesNotExist: - return JsonResponse({"authMethod": "credentials"}) + return JsonResponse(default_response) + + has_password = user.has_usable_password() # SSO user — find which provider they used - if not user.has_usable_password(): - social_acc = user.socialaccount_set.first() - if social_acc: - return JsonResponse({"authMethod": "sso", "ssoProvider": social_acc.provider}) + org_providers = OrganisationSSOProvider.objects.filter( + organisation__organisationmember__user=user, + organisation__organisationmember__deleted_at=None, + enabled=True, + ).select_related("organisation").distinct() - # Password user or edge case (no password, no social account) — show password field - return JsonResponse({"authMethod": "credentials"}) + sso_methods = [ + { + "id": str(provider.id), + "providerType": "oidc", + "provider": provider.provider_type, + "providerName": provider.name, + "enforced": provider.organisation.require_sso, + } + for provider in org_providers + ] + + return JsonResponse({ + "authMethods": { + "password": has_password, + "sso": sso_methods, + } + }) diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py index 47d6dad24..71af40829 100644 --- a/backend/tests/test_auth_password.py +++ b/backend/tests/test_auth_password.py @@ -543,45 +543,52 @@ def test_rejects_missing_fields(self): class EmailCheckTest(_ThrottleClearMixin, unittest.TestCase): """Tests for POST /auth/email/check/.""" + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_returns_credentials_for_password_user(self, mock_get_user): - """Known password user returns authMethod=credentials.""" + def test_returns_credentials_for_password_user(self, mock_get_user, mock_om, mock_sso): + """Known password user returns password=True, sso=[].""" User = MagicMock() user = MagicMock() user.has_usable_password.return_value = True + user.socialaccount_set.first.return_value = None User.objects.get.return_value = user mock_get_user.return_value = User + mock_om.objects.filter.return_value.select_related.return_value = [] request = _make_post("/auth/email/check/", {"email": "alice@example.com"}) response = email_check(request) self.assertEqual(response.status_code, 200) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "credentials") + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_returns_sso_for_sso_user(self, mock_get_user): - """Known SSO user returns authMethod=sso with provider.""" + def test_returns_empty_sso_for_instance_sso_user(self, mock_get_user, mock_om, mock_sso): + """Instance-level SSO users get sso=[] (buttons are on the first screen).""" User = MagicMock() user = MagicMock() user.has_usable_password.return_value = False - social_acc = MagicMock() - social_acc.provider = "google" - user.socialaccount_set.first.return_value = social_acc User.objects.get.return_value = user mock_get_user.return_value = User + mock_om.objects.filter.return_value.select_related.return_value = [] request = _make_post("/auth/email/check/", {"email": "bob@example.com"}) response = email_check(request) self.assertEqual(response.status_code, 200) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "sso") - self.assertEqual(data["ssoProvider"], "google") + self.assertFalse(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_returns_credentials_for_unknown_email(self, mock_get_user): - """Unknown email returns authMethod=credentials (no enumeration leak).""" + def test_returns_credentials_for_unknown_email(self, mock_get_user, mock_om, mock_sso): + """Unknown email returns password=True, sso=[] (no enumeration leak).""" User = MagicMock() from api.models import CustomUser User.DoesNotExist = CustomUser.DoesNotExist @@ -593,7 +600,8 @@ def test_returns_credentials_for_unknown_email(self, mock_get_user): self.assertEqual(response.status_code, 200) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "credentials") + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) def test_rejects_missing_email(self): """Missing email returns 400.""" @@ -855,32 +863,34 @@ def test_sso_user_cannot_password_login(self, mock_get_user): response = password_login(request) self.assertEqual(response.status_code, 401) + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_email_check_routes_sso_user_to_provider(self, mock_get_user): - """email_check returns SSO provider for SSO user.""" + def test_email_check_instance_sso_user_gets_empty_sso(self, mock_get_user, mock_om, mock_sso): + """Instance-level SSO user gets sso=[] (buttons are on the first screen).""" User = MagicMock() user = MagicMock() user.has_usable_password.return_value = False - social_acc = MagicMock() - social_acc.provider = "okta-oidc" - user.socialaccount_set.first.return_value = social_acc User.objects.get.return_value = user mock_get_user.return_value = User + mock_om.objects.filter.return_value.select_related.return_value = [] request = _make_post("/auth/email/check/", {"email": "sso-user@example.com"}) response = email_check(request) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "sso") - self.assertEqual(data["ssoProvider"], "okta-oidc") + self.assertFalse(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) class EmailCheckNoEnumerationTest(_ThrottleClearMixin, unittest.TestCase): """email_check must not leak whether an email is registered.""" + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_unknown_and_password_user_same_response(self, mock_get_user): - """Unknown email and password user both return 'credentials'.""" + def test_unknown_and_password_user_same_response(self, mock_get_user, mock_om, mock_sso): + """Unknown email and password user both return password=True, sso=[].""" User = MagicMock() from api.models import CustomUser @@ -897,15 +907,18 @@ def test_unknown_and_password_user_same_response(self, mock_get_user): User.objects.get.side_effect = None pw_user = MagicMock() pw_user.has_usable_password.return_value = True + pw_user.socialaccount_set.first.return_value = None User.objects.get.return_value = pw_user + mock_om.objects.filter.return_value.select_related.return_value = [] req2 = _make_post("/auth/email/check/", {"email": "known@example.com"}) resp2 = email_check(req2) data2 = json.loads(resp2.content) - # Both should return identical authMethod - self.assertEqual(data1["authMethod"], data2["authMethod"]) - self.assertEqual(data1["authMethod"], "credentials") + # Both should return identical authMethods + self.assertEqual(data1["authMethods"], data2["authMethods"]) + self.assertTrue(data1["authMethods"]["password"]) + self.assertEqual(data1["authMethods"]["sso"], []) class PasswordChangeFlowTest(_ThrottleClearMixin, unittest.TestCase): @@ -1030,34 +1043,39 @@ def test_sso_user_cannot_change_password(self): response = password_change(request) self.assertEqual(response.status_code, 400) + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_email_check_password_user_gets_credentials(self, mock_get_user): - """Password user routed to credentials (password field).""" + def test_email_check_password_user_gets_credentials(self, mock_get_user, mock_om, mock_sso): + """Password user returns password=True.""" User = MagicMock() user = MagicMock() user.has_usable_password.return_value = True + user.socialaccount_set.first.return_value = None User.objects.get.return_value = user mock_get_user.return_value = User + mock_om.objects.filter.return_value.select_related.return_value = [] request = _make_post("/auth/email/check/", {"email": "pw@example.com"}) response = email_check(request) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "credentials") + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") @patch("api.views.auth_password.get_user_model") - def test_email_check_sso_user_gets_provider(self, mock_get_user): - """SSO user routed to their SSO provider.""" + def test_email_check_instance_sso_user_gets_empty_sso(self, mock_get_user, mock_om, mock_sso): + """Instance-level SSO user gets sso=[] (buttons are on the first screen).""" User = MagicMock() user = MagicMock() user.has_usable_password.return_value = False - social = MagicMock() - social.provider = "github" - user.socialaccount_set.first.return_value = social User.objects.get.return_value = user mock_get_user.return_value = User + mock_om.objects.filter.return_value.select_related.return_value = [] request = _make_post("/auth/email/check/", {"email": "sso@example.com"}) response = email_check(request) data = json.loads(response.content) - self.assertEqual(data["authMethod"], "sso") - self.assertEqual(data["ssoProvider"], "github") + self.assertFalse(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) From 634f9f64201c3df7c77f262ad87069e5d7ecbc76 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:20:51 +0800 Subject: [PATCH 047/100] feat(backend): enforce org SSO on GraphQL queries via middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrgSSOEnforcementMiddleware raises SSO_REQUIRED on org-scoped resolvers when org.require_sso=True and the caller's session wasn't established via that org's SSO flow. An SSO session is accepted only when both auth_method="sso" and auth_sso_org_id matches the target org — so instance-level SSO sessions (Google/GitHub/GitLab with no auth_sso_org_id) don't cross over. Resolves org_id from kwargs: organisation_id | org_id | app_id (via App → organisation) | env_id | environment_id (via Environment → App → organisation). Every lookup is cached per-request on info.context so a GraphQL document that touches the same org/app/env across many resolvers doesn't re-query. Unauthenticated callers and non-org-scoped resolvers pass through. Membership-scoped lists like `organisations` still return all memberships so users can see their locked orgs and re-auth. Registered ahead of IPWhitelistMiddleware. --- backend/backend/graphene/middleware.py | 147 ++++++++++++++++++++++++- backend/backend/settings.py | 1 + 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/backend/backend/graphene/middleware.py b/backend/backend/graphene/middleware.py index 231b02911..5a7eed619 100644 --- a/backend/backend/graphene/middleware.py +++ b/backend/backend/graphene/middleware.py @@ -1,6 +1,6 @@ from graphql import GraphQLResolveInfo from graphql import GraphQLError -from api.models import NetworkAccessPolicy, Organisation, OrganisationMember +from api.models import App, Environment, NetworkAccessPolicy, Organisation, OrganisationMember from itertools import chain from api.utils.access.ip import get_client_ip @@ -17,6 +17,151 @@ def __init__(self, organisation_name: str): ) +class SSORequiredError(GraphQLError): + def __init__(self, organisation_name: str, organisation_id: str): + super().__init__( + message=f"{organisation_name} requires Single Sign-On. Please sign in via SSO to continue.", + extensions={ + "code": "SSO_REQUIRED", + "organisation_name": organisation_name, + "organisation_id": organisation_id, + }, + ) + + +class OrgSSOEnforcementMiddleware: + """ + Graphene middleware to enforce per-org SSO requirements. + + When org.require_sso=True, the session must have been established via the + org-level SSO flow *for this specific org*. Instance-level SSO (Google, + GitHub, GitLab) sets auth_method="sso" but does NOT set auth_sso_org_id, + so those sessions are blocked — they cannot bypass org-level enforcement. + + Provider rotation (admin swaps Entra→Okta) is not yet enforced; sessions + authenticated against the previous provider for this org still pass. + auth_sso_provider_id is stored on the session for a future tightening. + + Resolves org from: organisation_id | org_id | app_id | env_id | + environment_id kwargs. Other resolvers fall through (membership-scoped + lists like `organisations` show all memberships, including locked orgs, + so the user can see them and re-auth via SSO). + + Per-request caching: a single GraphQL document often pulls many + org-scoped fields. Without caching, each resolver hit re-queries + Organisation (and for app_id/env_id, App and Environment too). The + middleware caches org_id resolution by kind + id, and the (blocked, + org_name) decision by org_id, on the request object — so the heavy + work happens once per org per HTTP request. + """ + + _ORG_CACHE_ATTR = "_org_sso_cache" + _APP_CACHE_ATTR = "_org_sso_app_to_org" + _ENV_CACHE_ATTR = "_org_sso_env_to_org" + + def resolve(self, next, root, info: GraphQLResolveInfo, **kwargs): + request = info.context + user = getattr(request, "user", None) + + if not user or not user.is_authenticated: + return next(root, info, **kwargs) + + org_id = self._resolve_org_id(request, kwargs) + if not org_id: + return next(root, info, **kwargs) + + decision = self._get_org_decision(request, org_id) + if decision is None: + # Org didn't exist / couldn't be loaded — let the resolver decide. + return next(root, info, **kwargs) + + blocked, org_name = decision + if not blocked: + return next(root, info, **kwargs) + + session = getattr(request, "session", None) + auth_method = session.get("auth_method") if session else None + session_org_id = session.get("auth_sso_org_id") if session else None + + if auth_method == "sso" and session_org_id == str(org_id): + return next(root, info, **kwargs) + + raise SSORequiredError(org_name, str(org_id)) + + @classmethod + def _get_org_decision(cls, request, org_id): + """Return (blocked, org_name) tuple, or None if the org can't be + loaded. Cached per request.""" + cache = getattr(request, cls._ORG_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(request, cls._ORG_CACHE_ATTR, cache) + + key = str(org_id) + if key in cache: + return cache[key] + + try: + org = Organisation.objects.only("require_sso", "name").get(id=org_id) + except Organisation.DoesNotExist: + cache[key] = None + return None + + decision = (bool(org.require_sso), org.name) + cache[key] = decision + return decision + + @classmethod + def _resolve_org_id(cls, request, kwargs): + # Direct org kwargs — no DB hit. + for key in ("organisation_id", "org_id"): + value = kwargs.get(key) + if value: + return value + # Resolve via App — cached per request. + app_id = kwargs.get("app_id") + if app_id: + return cls._lookup_app_org(request, app_id) + # Resolve via Environment — cached per request. + env_id = kwargs.get("env_id") or kwargs.get("environment_id") + if env_id: + return cls._lookup_env_org(request, env_id) + return None + + @classmethod + def _lookup_app_org(cls, request, app_id): + cache = getattr(request, cls._APP_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(request, cls._APP_CACHE_ATTR, cache) + key = str(app_id) + if key in cache: + return cache[key] + try: + org_id = App.objects.only("organisation_id").get(id=app_id).organisation_id + except App.DoesNotExist: + org_id = None + cache[key] = org_id + return org_id + + @classmethod + def _lookup_env_org(cls, request, env_id): + cache = getattr(request, cls._ENV_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(request, cls._ENV_CACHE_ATTR, cache) + key = str(env_id) + if key in cache: + return cache[key] + try: + env = Environment.objects.only("app_id").get(id=env_id) + org_id = cls._lookup_app_org(request, env.app_id) + except Environment.DoesNotExist: + org_id = None + cache[key] = org_id + return org_id + + class IPWhitelistMiddleware: """ Graphene middleware to enforce network access policy for human users diff --git a/backend/backend/settings.py b/backend/backend/settings.py index e15d72bbd..712cd2128 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -278,6 +278,7 @@ def get_version(): GRAPHENE = { "SCHEMA": "backend.schema.schema", "MIDDLEWARE": [ + "backend.graphene.middleware.OrgSSOEnforcementMiddleware", "backend.graphene.middleware.IPWhitelistMiddleware", ], } From 2277e0c74e6d4cda017ad0e4bb3dff15e804883d Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:21:01 +0800 Subject: [PATCH 048/100] feat(backend): add disable_org_sso management command Out-of-band tool for operators to inspect and toggle an org's SSO state from the backend host: python manage.py disable_org_sso --org # show state python manage.py disable_org_sso --org --disable-enforcement python manage.py disable_org_sso --org --disable-providers --show prints require_sso, enabled providers, and ids. The other flags flip the flags directly on the DB. --- .../management/commands/disable_org_sso.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 backend/api/management/commands/disable_org_sso.py diff --git a/backend/api/management/commands/disable_org_sso.py b/backend/api/management/commands/disable_org_sso.py new file mode 100644 index 000000000..ebe21b3e1 --- /dev/null +++ b/backend/api/management/commands/disable_org_sso.py @@ -0,0 +1,82 @@ +"""Helper command to disable SSO enforcement and/or delete SSO provider config. + +Usage: + python manage.py disable_org_sso --org "contoso" --show + python manage.py disable_org_sso --org "contoso" --disable-enforcement + python manage.py disable_org_sso --org "contoso" --delete-provider + python manage.py disable_org_sso --org "contoso" --disable-enforcement --delete-provider +""" + +import logging +from django.core.management.base import BaseCommand +from api.models import Organisation, OrganisationSSOProvider + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Disable SSO enforcement and/or delete SSO provider config for an organisation" + + def add_arguments(self, parser): + parser.add_argument( + "--org", required=True, help="Organisation name" + ) + parser.add_argument( + "--show", + action="store_true", + help="Show current SSO state for the organisation", + ) + parser.add_argument( + "--disable-enforcement", + action="store_true", + help="Set require_sso=False on the organisation", + ) + parser.add_argument( + "--delete-provider", + action="store_true", + help="Delete all SSO provider configs for the organisation", + ) + + def handle(self, *args, **options): + org_name = options["org"] + + try: + org = Organisation.objects.get(name=org_name) + except Organisation.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Organisation '{org_name}' not found")) + return + + providers = OrganisationSSOProvider.objects.filter(organisation=org) + + if options["show"]: + self.stdout.write(f"\nOrganisation: {org.name} (id={org.id})") + self.stdout.write(f" require_sso: {org.require_sso}") + self.stdout.write(f" SSO providers: {providers.count()}") + for p in providers: + self.stdout.write( + f" - {p.name} ({p.provider_type}) enabled={p.enabled} id={p.id}" + ) + self.stdout.write("") + return + + if not options["disable_enforcement"] and not options["delete_provider"]: + self.stderr.write( + self.style.ERROR( + "Specify --show, --disable-enforcement, and/or --delete-provider" + ) + ) + return + + if options["disable_enforcement"]: + org.require_sso = False + org.save() + msg = f"SSO enforcement disabled for '{org_name}'" + self.stdout.write(self.style.SUCCESS(msg)) + logger.info(msg) + + if options["delete_provider"]: + count = providers.count() + providers.delete() + msg = f"Deleted {count} SSO provider(s) for '{org_name}'" + self.stdout.write(self.style.SUCCESS(msg)) + logger.info(msg) From e6de5ec26bb3809ebeeb6dceb43f67cb86243872 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:21:17 +0800 Subject: [PATCH 049/100] test(backend): tests for org SSO feature Unit coverage for every new surface: - email_check with org SSO: unknown email, password user with no SSO, user with active provider, enforcement flag - Create/Update/Delete/Test mutations: permission checks, provider registry lookups, config validation, merge behavior on update, enforcement cleanup on deactivation - UpdateOrganisationSecurityMutation: enforcement requires an active provider; admin on a password session is logged out on enable; admin on an SSO session keeps going; disabling never logs out - OrgSSOAuthorizeView: redirects to Entra/Okta, 404 for invalid config, stores sso_return_to from both snake_case and legacy camelCase query params - Entra adapter runs with APP_HOST=cloud - OrgSSOEnforcementMiddleware: password session blocked, SSO session accepted only when auth_sso_org_id matches, resolves org from app_id and env_id, passes through when org missing, caches Organisation / App / Environment lookups per-request - Post-login redirect accepts same-origin relative paths only - SSO callback session marker writes + password-login marker clears - Django login() behavior on set_password (documents why the save/restore in password_change is necessary) --- backend/tests/test_org_sso.py | 1468 +++++++++++++++++++++++++++++++++ 1 file changed, 1468 insertions(+) create mode 100644 backend/tests/test_org_sso.py diff --git a/backend/tests/test_org_sso.py b/backend/tests/test_org_sso.py new file mode 100644 index 000000000..ea64c5db3 --- /dev/null +++ b/backend/tests/test_org_sso.py @@ -0,0 +1,1468 @@ +"""Tests for per-org SSO configuration. + +Covers model, mutations, email_check, authorize view, and enforcement. +Uses unittest.TestCase with mocked ORM — no database required. +""" + +import json +import unittest +from unittest.mock import patch, MagicMock, PropertyMock, call + +from django.core.cache import cache +from django.test import RequestFactory +from django.contrib.sessions.middleware import SessionMiddleware +from rest_framework.test import APIRequestFactory, force_authenticate + + +class _ThrottleClearMixin: + def setUp(self): + super().setUp() + cache.clear() + + +def _add_session_to_request(request): + middleware = SessionMiddleware(lambda req: None) + middleware.process_request(request) + request.session.save() + + +def _make_post(path, data, user=None): + factory = APIRequestFactory() + request = factory.post(path, data=data, format="json") + _add_session_to_request(request) + if user: + force_authenticate(request, user=user) + return request + + +def _make_get(path): + factory = RequestFactory() + request = factory.get(path) + _add_session_to_request(request) + return request + + +# --------------------------------------------------------------------------- +# email_check with org SSO +# --------------------------------------------------------------------------- + +class EmailCheckOrgSSOTest(_ThrottleClearMixin, unittest.TestCase): + """Tests for email_check returning org-level SSO providers.""" + + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") + @patch("api.views.auth_password.get_user_model") + def test_unknown_email_returns_password_only( + self, mock_get_user, mock_om, mock_sso_provider + ): + """Unknown email returns password=True, sso=[] (anti-enumeration).""" + from api.views.auth_password import email_check + from django.contrib.auth import get_user_model as real_get_user_model + + RealUser = real_get_user_model() + + User = MagicMock() + User.DoesNotExist = RealUser.DoesNotExist + User.objects.get.side_effect = RealUser.DoesNotExist + mock_get_user.return_value = User + + request = _make_post("/auth/email/check/", {"email": "nobody@example.com"}) + response = email_check(request) + + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") + @patch("api.views.auth_password.get_user_model") + def test_password_user_no_sso(self, mock_get_user, mock_om, mock_sso_provider): + """Password user with no org SSO returns password=True, sso=[].""" + from api.views.auth_password import email_check + + user = MagicMock() + user.has_usable_password.return_value = True + user.socialaccount_set.first.return_value = None + + User = MagicMock() + User.objects.get.return_value = user + User.DoesNotExist = Exception + mock_get_user.return_value = User + + mock_om.objects.filter.return_value.select_related.return_value = [] + + request = _make_post("/auth/email/check/", {"email": "alice@example.com"}) + response = email_check(request) + + data = json.loads(response.content) + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") + @patch("api.views.auth_password.get_user_model") + def test_user_with_org_sso_returns_provider( + self, mock_get_user, mock_om, mock_sso_provider + ): + """User in org with SSO gets org provider in sso list.""" + from api.views.auth_password import email_check + + user = MagicMock() + user.has_usable_password.return_value = True + user.socialaccount_set.first.return_value = None + + User = MagicMock() + User.objects.get.return_value = user + User.DoesNotExist = Exception + mock_get_user.return_value = User + + org = MagicMock() + org.require_sso = False + + provider = MagicMock() + provider.id = "test-config-id" + provider.provider_type = "entra_id" + provider.name = "Microsoft Entra ID" + provider.organisation = org + # email_check now does a single join query with select_related + + # distinct instead of a per-membership loop. + ( + mock_sso_provider.objects.filter.return_value + .select_related.return_value + .distinct.return_value + ) = [provider] + + request = _make_post("/auth/email/check/", {"email": "alice@example.com"}) + response = email_check(request) + + data = json.loads(response.content) + self.assertTrue(data["authMethods"]["password"]) + self.assertEqual(len(data["authMethods"]["sso"]), 1) + self.assertEqual(data["authMethods"]["sso"][0]["id"], "test-config-id") + self.assertEqual(data["authMethods"]["sso"][0]["providerType"], "oidc") + self.assertEqual(data["authMethods"]["sso"][0]["provider"], "entra_id") + self.assertEqual(data["authMethods"]["sso"][0]["providerName"], "Microsoft Entra ID") + self.assertFalse(data["authMethods"]["sso"][0]["enforced"]) + + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") + @patch("api.views.auth_password.get_user_model") + def test_enforced_sso_marked(self, mock_get_user, mock_om, mock_sso_provider): + """When org.require_sso=True, enforced=True in response.""" + from api.views.auth_password import email_check + + user = MagicMock() + user.has_usable_password.return_value = True + user.socialaccount_set.first.return_value = None + + User = MagicMock() + User.objects.get.return_value = user + User.DoesNotExist = Exception + mock_get_user.return_value = User + + org = MagicMock() + org.require_sso = True + + provider = MagicMock() + provider.id = "enforced-id" + provider.provider_type = "okta" + provider.name = "Okta" + provider.organisation = org + ( + mock_sso_provider.objects.filter.return_value + .select_related.return_value + .distinct.return_value + ) = [provider] + + request = _make_post("/auth/email/check/", {"email": "alice@example.com"}) + response = email_check(request) + + data = json.loads(response.content) + self.assertTrue(data["authMethods"]["sso"][0]["enforced"]) + + @patch("api.views.auth_password.OrganisationSSOProvider") + @patch("api.views.auth_password.OrganisationMember") + @patch("api.views.auth_password.get_user_model") + def test_instance_sso_user_gets_empty_sso( + self, mock_get_user, mock_om, mock_sso_provider + ): + """Instance-level SSO user gets sso=[] (buttons are on the first screen).""" + from api.views.auth_password import email_check + + user = MagicMock() + user.has_usable_password.return_value = False + + User = MagicMock() + User.objects.get.return_value = user + User.DoesNotExist = Exception + mock_get_user.return_value = User + + mock_om.objects.filter.return_value.select_related.return_value = [] + + request = _make_post("/auth/email/check/", {"email": "alice@example.com"}) + response = email_check(request) + + data = json.loads(response.content) + self.assertFalse(data["authMethods"]["password"]) + self.assertEqual(data["authMethods"]["sso"], []) + + +# --------------------------------------------------------------------------- +# GraphQL mutations +# --------------------------------------------------------------------------- + +class CreateSSOProviderMutationTest(unittest.TestCase): + """Tests for CreateOrganisationSSOProviderMutation.""" + + @patch("backend.graphene.mutations.sso.OrganisationMember") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_create_provider(self, mock_perm, mock_org_cls, mock_provider_cls, mock_member_cls): + from backend.graphene.mutations.sso import CreateOrganisationSSOProviderMutation + + org = MagicMock() + org.plan = "EN" # Enterprise plan required for SSO + mock_org_cls.ENTERPRISE_PLAN = "EN" + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = False + # Registry is used directly now, no need to mock PROVIDER_TYPES + + member = MagicMock() + mock_member_cls.objects.get.return_value = member + + created = MagicMock() + created.id = "new-id" + mock_provider_cls.objects.create.return_value = created + + info = MagicMock() + info.context.user = MagicMock() + + result = CreateOrganisationSSOProviderMutation.mutate( + None, + info, + org_id="org-1", + provider_type="entra_id", + name="Contoso Entra", + config={ + "tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "ph:v1:abc:ciphertext", + }, + ) + + self.assertEqual(result.provider_id, "new-id") + mock_provider_cls.objects.create.assert_called_once() + + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=False) + def test_create_provider_no_permission(self, mock_perm, mock_org_cls): + from backend.graphene.mutations.sso import CreateOrganisationSSOProviderMutation + from graphql import GraphQLError + + mock_org_cls.objects.get.return_value = MagicMock() + + info = MagicMock() + info.context.user = MagicMock() + + with self.assertRaises(GraphQLError): + CreateOrganisationSSOProviderMutation.mutate( + None, info, org_id="org-1", provider_type="entra_id", + name="Test", config={} + ) + + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_create_duplicate_type_rejected(self, mock_perm, mock_org_cls, mock_provider_cls): + from backend.graphene.mutations.sso import CreateOrganisationSSOProviderMutation + from graphql import GraphQLError + + org = MagicMock() + org.plan = "EN" + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = True + # Registry is used directly now, no need to mock PROVIDER_TYPES + + info = MagicMock() + info.context.user = MagicMock() + + with self.assertRaises(GraphQLError): + CreateOrganisationSSOProviderMutation.mutate( + None, info, org_id="org-1", provider_type="entra_id", + name="Dup", config={} + ) + + +class UpdateSSOProviderMutationTest(unittest.TestCase): + """Tests for UpdateOrganisationSSOProviderMutation.""" + + @patch("backend.graphene.mutations.sso.OrganisationMember") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_update_merges_config(self, mock_perm, mock_provider_cls, mock_member_cls): + from backend.graphene.mutations.sso import UpdateOrganisationSSOProviderMutation + + provider = MagicMock() + provider.provider_type = "entra_id" + provider.config = { + "tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "ph:v1:pk:ct", + } + provider.organisation = MagicMock() + provider.organisation.plan = "EN" # Enterprise plan required for SSO + mock_provider_cls.objects.get.return_value = provider + mock_member_cls.objects.get.return_value = MagicMock() + + info = MagicMock() + info.context.user = MagicMock() + + # Update tenant_id but not client_secret (empty string means keep existing) + result = UpdateOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1", + config={ + "tenant_id": "11111111-2222-3333-4444-555555555555", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "", + }, + ) + + self.assertTrue(result.ok) + # client_secret should be preserved + self.assertEqual(provider.config["client_secret"], "ph:v1:pk:ct") + self.assertEqual( + provider.config["tenant_id"], "11111111-2222-3333-4444-555555555555" + ) + + @patch("backend.graphene.mutations.sso.OrganisationMember") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_enable_deactivates_others(self, mock_perm, mock_provider_cls, mock_member_cls): + from backend.graphene.mutations.sso import UpdateOrganisationSSOProviderMutation + + provider = MagicMock() + provider.config = {} + provider.organisation = MagicMock() + provider.organisation.plan = "EN" # Enterprise plan required for SSO + mock_provider_cls.objects.get.return_value = provider + mock_member_cls.objects.get.return_value = MagicMock() + + info = MagicMock() + info.context.user = MagicMock() + + UpdateOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1", enabled=True + ) + + # Should have called filter + exclude + update to deactivate others + mock_provider_cls.objects.filter.assert_called() + + +class DeleteSSOProviderMutationTest(unittest.TestCase): + """Tests for DeleteOrganisationSSOProviderMutation.""" + + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_delete_clears_enforcement(self, mock_perm, mock_provider_cls): + from backend.graphene.mutations.sso import DeleteOrganisationSSOProviderMutation + + org = MagicMock() + org.require_sso = True + + provider = MagicMock() + provider.enabled = True + provider.organisation = org + mock_provider_cls.objects.get.return_value = provider + + info = MagicMock() + info.context.user = MagicMock() + + result = DeleteOrganisationSSOProviderMutation.mutate(None, info, provider_id="p1") + + self.assertTrue(result.ok) + self.assertFalse(org.require_sso) + org.save.assert_called_once() + provider.delete.assert_called_once() + + +class UpdateOrgSecurityMutationTest(unittest.TestCase): + """Tests for UpdateOrganisationSecurityMutation.""" + + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_enforce_sso_requires_active_provider( + self, mock_perm, mock_org_cls, mock_provider_cls + ): + from backend.graphene.mutations.sso import UpdateOrganisationSecurityMutation + from graphql import GraphQLError + + org = MagicMock() + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = False + + info = MagicMock() + info.context.user = MagicMock() + + with self.assertRaises(GraphQLError): + UpdateOrganisationSecurityMutation.mutate( + None, info, org_id="org-1", require_sso=True + ) + + @patch("backend.graphene.mutations.sso.django_logout") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_enforce_sso_with_active_provider( + self, mock_perm, mock_org_cls, mock_provider_cls, mock_logout + ): + from backend.graphene.mutations.sso import UpdateOrganisationSecurityMutation + + org = MagicMock() + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = True + + info = MagicMock() + info.context.user = MagicMock() + info.context.session = {"auth_method": "sso"} + + result = UpdateOrganisationSecurityMutation.mutate( + None, info, org_id="org-1", require_sso=True + ) + + self.assertTrue(result.ok) + self.assertTrue(org.require_sso) + org.save.assert_called_once() + + @patch("backend.graphene.mutations.sso.django_logout") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_enforce_sso_invalidates_password_admin_session( + self, mock_perm, mock_org_cls, mock_provider_cls, mock_logout + ): + """Admin enforcing SSO from a password session is logged out so they + must re-auth via SSO (no half-state where this session still works).""" + from backend.graphene.mutations.sso import UpdateOrganisationSecurityMutation + + org = MagicMock() + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = True + + info = MagicMock() + info.context.session = {"auth_method": "password"} + + result = UpdateOrganisationSecurityMutation.mutate( + None, info, org_id="org-1", require_sso=True + ) + + self.assertTrue(result.ok) + self.assertTrue(result.session_invalidated) + mock_logout.assert_called_once_with(info.context) + + @patch("backend.graphene.mutations.sso.django_logout") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_enforce_sso_keeps_sso_admin_session( + self, mock_perm, mock_org_cls, mock_provider_cls, mock_logout + ): + """Admin enforcing SSO from an already-SSO session is not logged out.""" + from backend.graphene.mutations.sso import UpdateOrganisationSecurityMutation + + org = MagicMock() + mock_org_cls.objects.get.return_value = org + mock_provider_cls.objects.filter.return_value.exists.return_value = True + + info = MagicMock() + info.context.session = {"auth_method": "sso"} + + result = UpdateOrganisationSecurityMutation.mutate( + None, info, org_id="org-1", require_sso=True + ) + + self.assertTrue(result.ok) + self.assertFalse(result.session_invalidated) + mock_logout.assert_not_called() + + @patch("backend.graphene.mutations.sso.django_logout") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.Organisation") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_disable_enforcement_does_not_logout( + self, mock_perm, mock_org_cls, mock_provider_cls, mock_logout + ): + """Turning enforcement OFF must not log out the admin.""" + from backend.graphene.mutations.sso import UpdateOrganisationSecurityMutation + + org = MagicMock() + mock_org_cls.objects.get.return_value = org + + info = MagicMock() + info.context.session = {"auth_method": "password"} + + result = UpdateOrganisationSecurityMutation.mutate( + None, info, org_id="org-1", require_sso=False + ) + + self.assertTrue(result.ok) + self.assertFalse(result.session_invalidated) + mock_logout.assert_not_called() + + +# --------------------------------------------------------------------------- +# OrgSSOAuthorizeView +# --------------------------------------------------------------------------- + +class OrgSSOAuthorizeViewTest(unittest.TestCase): + """Tests for GET /auth/sso/org//authorize/.""" + + @patch("api.views.sso._get_callback_url", return_value="https://localhost/api/auth/callback/entra-id-oidc") + @patch("api.views.sso._get_oidc_endpoints") + def test_authorize_redirects_to_entra(self, mock_endpoints, mock_callback): + from api.views.sso import OrgSSOAuthorizeView + + mock_endpoints.return_value = { + "authorize_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + } + + provider = MagicMock() + provider.id = "config-123" + provider.provider_type = "entra_id" + + config = { + "tenant_id": "72f988bf-test", + "client_id": "app-client-id", + "client_secret": "decrypted-secret", + } + + with patch("api.utils.sso.get_org_sso_config", return_value=(provider, config)): + view = OrgSSOAuthorizeView() + request = _make_get("/auth/sso/org/config-123/authorize/") + response = view.get(request, config_id="config-123") + + self.assertEqual(response.status_code, 302) + self.assertIn("login.microsoftonline.com", response.url) + self.assertIn("client_id=app-client-id", response.url) + + @patch("api.views.sso._get_callback_url", return_value="https://localhost/api/auth/callback/okta-oidc") + @patch("api.views.sso._get_oidc_endpoints") + def test_authorize_redirects_to_okta(self, mock_endpoints, mock_callback): + from api.views.sso import OrgSSOAuthorizeView + + mock_endpoints.return_value = { + "authorize_url": "https://dev-12345.okta.com/oauth2/v1/authorize", + "token_url": "https://dev-12345.okta.com/oauth2/v1/token", + } + + provider = MagicMock() + provider.id = "okta-config" + provider.provider_type = "okta" + + config = { + "issuer": "https://dev-12345.okta.com", + "client_id": "okta-client-id", + "client_secret": "decrypted-secret", + } + + with patch("api.utils.sso.get_org_sso_config", return_value=(provider, config)): + view = OrgSSOAuthorizeView() + request = _make_get("/auth/sso/org/okta-config/authorize/") + response = view.get(request, config_id="okta-config") + + self.assertEqual(response.status_code, 302) + self.assertIn("okta.com", response.url) + + def test_authorize_invalid_config_returns_404(self): + from api.views.sso import OrgSSOAuthorizeView + + with patch("api.utils.sso.get_org_sso_config", side_effect=Exception("not found")): + view = OrgSSOAuthorizeView() + request = _make_get("/auth/sso/org/bad-id/authorize/") + response = view.get(request, config_id="bad-id") + + self.assertEqual(response.status_code, 404) + + @patch("api.views.sso._get_callback_url", return_value="https://localhost/api/auth/callback/entra-id-oidc") + @patch("api.views.sso._get_oidc_endpoints") + def test_authorize_stores_sso_return_to_from_snake_case( + self, mock_endpoints, mock_callback + ): + """Regression: djangorestframework_camel_case middleware rewrites + incoming camelCase query params to snake_case, so ?callbackUrl=... + arrives as 'callback_url' in request.GET. The view must read the + snake_case form to populate sso_return_to — otherwise Test SSO and + other deep-link flows silently bounce users to '/'.""" + from api.views.sso import OrgSSOAuthorizeView + + mock_endpoints.return_value = { + "authorize_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + } + + provider = MagicMock() + provider.id = "config-123" + provider.provider_type = "entra_id" + config = { + "tenant_id": "t", + "client_id": "c", + "client_secret": "s", + } + + with patch("api.utils.sso.get_org_sso_config", return_value=(provider, config)): + view = OrgSSOAuthorizeView() + request = _make_get( + "/auth/sso/org/config-123/authorize/" + "?callback_url=/phase/access/sso/oidc%3Fsso_test%3Dconfig-123" + ) + view.get(request, config_id="config-123") + + self.assertEqual( + request.session.get("sso_return_to"), + "/phase/access/sso/oidc?sso_test=config-123", + ) + + @patch("api.views.sso._get_callback_url", return_value="https://localhost/api/auth/callback/entra-id-oidc") + @patch("api.views.sso._get_oidc_endpoints") + def test_authorize_stores_sso_return_to_from_legacy_camel_case( + self, mock_endpoints, mock_callback + ): + """Fallback: if the middleware is bypassed for any reason, the view + still reads the raw camelCase 'callbackUrl'.""" + from api.views.sso import OrgSSOAuthorizeView + + mock_endpoints.return_value = { + "authorize_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + } + + provider = MagicMock() + provider.id = "config-123" + provider.provider_type = "entra_id" + config = {"tenant_id": "t", "client_id": "c", "client_secret": "s"} + + with patch("api.utils.sso.get_org_sso_config", return_value=(provider, config)): + view = OrgSSOAuthorizeView() + request = _make_get( + "/auth/sso/org/config-123/authorize/" + "?callbackUrl=/phase/access/sso/oidc" + ) + view.get(request, config_id="config-123") + + self.assertEqual( + request.session.get("sso_return_to"), + "/phase/access/sso/oidc", + ) + + @patch("api.views.sso._get_callback_url", return_value="https://localhost/api/auth/callback/entra-id-oidc") + @patch("api.views.sso._get_oidc_endpoints") + def test_authorize_no_callback_url_leaves_return_to_unset( + self, mock_endpoints, mock_callback + ): + """No callbackUrl in the request → sso_return_to is not set (so the + callback falls back to '/' after login).""" + from api.views.sso import OrgSSOAuthorizeView + + mock_endpoints.return_value = { + "authorize_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + } + + provider = MagicMock() + provider.id = "config-123" + provider.provider_type = "entra_id" + config = {"tenant_id": "t", "client_id": "c", "client_secret": "s"} + + with patch("api.utils.sso.get_org_sso_config", return_value=(provider, config)): + view = OrgSSOAuthorizeView() + request = _make_get("/auth/sso/org/config-123/authorize/") + view.get(request, config_id="config-123") + + self.assertIsNone(request.session.get("sso_return_to")) + + +class SSOReturnToSafetyTest(unittest.TestCase): + """Defense-in-depth checks for the sso_return_to redirect in the callback. + Only same-origin relative paths may be honored; cross-origin / protocol- + relative URLs must be rejected even if somehow stored in session.""" + + def _evaluate(self, return_to): + """Mirror the guard in SSOCallbackView.get: accept only a string + starting with a single '/' (not '//').""" + return bool( + return_to + and return_to.startswith("/") + and not return_to.startswith("//") + ) + + def test_accepts_same_origin_relative_path(self): + self.assertTrue(self._evaluate("/phase/access/sso/oidc")) + self.assertTrue(self._evaluate("/")) + self.assertTrue(self._evaluate("/foo?bar=baz")) + + def test_rejects_protocol_relative_url(self): + self.assertFalse(self._evaluate("//evil.com/phish")) + self.assertFalse(self._evaluate("//evil.com")) + + def test_rejects_absolute_urls_and_empty(self): + self.assertFalse(self._evaluate("https://evil.com/phish")) + self.assertFalse(self._evaluate("http://evil.com")) + self.assertFalse(self._evaluate("")) + self.assertFalse(self._evaluate(None)) + + +# --------------------------------------------------------------------------- +# Cloud-mode guard removal +# --------------------------------------------------------------------------- + +class CloudModeGuardRemovalTest(unittest.TestCase): + """Verify EE adapters no longer block on APP_HOST=cloud.""" + + @patch("ee.authentication.sso.oidc.entraid.views.settings") + @patch("ee.authentication.sso.oidc.entraid.views.ActivatedPhaseLicense") + @patch("ee.authentication.sso.oidc.entraid.views.send_login_email") + @patch("ee.authentication.sso.oidc.entraid.views.get_adapter") + def test_entra_adapter_works_on_cloud( + self, mock_get_adapter, mock_send_email, mock_license, mock_settings + ): + from ee.authentication.sso.oidc.entraid.views import CustomMicrosoftGraphOAuth2Adapter + + mock_settings.APP_HOST = "cloud" + + # Mock the response from Microsoft Graph + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "user-123", + "displayName": "Test User", + "mail": "test@example.com", + } + mock_get_adapter.return_value.get_requests_session.return_value.get.return_value = mock_response + + # Mock get_provider and create adapter properly + mock_social_login = MagicMock() + mock_social_login.user.email = "test@example.com" + mock_social_login.account.extra_data = {"name": "Test User"} + + # Patch profile_url property on the class since it's a class attribute/property + with patch.object( + CustomMicrosoftGraphOAuth2Adapter, "profile_url", + new_callable=PropertyMock, return_value="https://graph.microsoft.com/v1.0/me" + ), patch.object( + CustomMicrosoftGraphOAuth2Adapter, "profile_url_params", + new_callable=PropertyMock, return_value={} + ): + adapter = CustomMicrosoftGraphOAuth2Adapter.__new__(CustomMicrosoftGraphOAuth2Adapter) + adapter.get_provider = MagicMock() + adapter.get_provider.return_value.sociallogin_from_response.return_value = mock_social_login + + mock_token = MagicMock() + mock_token.token = "fake-token" + + # Should NOT raise OAuth2Error — cloud mode is no longer blocked + result = adapter.complete_login(MagicMock(), MagicMock(), mock_token) + self.assertIsNotNone(result) + + +# --------------------------------------------------------------------------- +# SSO config helpers +# --------------------------------------------------------------------------- + +class SSOConfigHelperTest(unittest.TestCase): + """Tests for get_org_sso_config.""" + + @patch("api.utils.crypto.decrypt_asymmetric", return_value="decrypted-secret") + @patch("api.utils.crypto.get_server_keypair", return_value=(b"\x01" * 32, b"\x02" * 32)) + @patch("api.models.OrganisationSSOProvider") + def test_get_org_sso_config_decrypts(self, mock_provider_cls, mock_keypair, mock_decrypt): + from api.utils.sso import get_org_sso_config + + provider = MagicMock() + provider.config = { + "tenant_id": "test-tenant", + "client_id": "test-client", + "client_secret": "ph:v1:encrypted", + } + mock_provider_cls.objects.get.return_value = provider + + result_provider, config = get_org_sso_config("config-id") + + self.assertEqual(config["client_secret"], "decrypted-secret") + self.assertEqual(config["tenant_id"], "test-tenant") + mock_decrypt.assert_called_once() + + +# --------------------------------------------------------------------------- +# OrgSSOEnforcementMiddleware +# --------------------------------------------------------------------------- + +class OrgSSOEnforcementMiddlewareTest(unittest.TestCase): + """Tests for the graphene middleware that blocks non-SSO sessions from + accessing SSO-enforced orgs.""" + + def _make_info(self, user_authenticated=True, session_auth_method=None, session_org_id=None): + info = MagicMock() + info.context.user = MagicMock() + info.context.user.is_authenticated = user_authenticated + session = {} + if session_auth_method is not None: + session["auth_method"] = session_auth_method + if session_org_id is not None: + session["auth_sso_org_id"] = session_org_id + info.context.session = session + return info + + def _next(self, root, info, **kwargs): + return "resolver_ran" + + @patch("backend.graphene.middleware.Organisation") + def test_passes_when_org_does_not_require_sso(self, mock_org_cls): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org = MagicMock(require_sso=False, name="acme", id="org-1") + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + result = mw.resolve(self._next, None, info, organisation_id="org-1") + self.assertEqual(result, "resolver_ran") + + @patch("backend.graphene.middleware.Organisation") + def test_passes_sso_session_against_enforced_org(self, mock_org_cls): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="sso", session_org_id="org-1") + + result = mw.resolve(self._next, None, info, organisation_id="org-1") + self.assertEqual(result, "resolver_ran") + + @patch("backend.graphene.middleware.Organisation") + def test_blocks_instance_sso_session_without_org_binding(self, mock_org_cls): + """Instance-level SSO (Google/GitHub/GitLab) sets auth_method=sso but + does NOT set auth_sso_org_id. Such a session must not bypass org-level + SSO enforcement — otherwise a user could sign in via Google and reach + an Entra-enforced org with the same email.""" + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="sso") # no org binding + + with self.assertRaises(SSORequiredError): + mw.resolve(self._next, None, info, organisation_id="org-1") + + @patch("backend.graphene.middleware.Organisation") + def test_blocks_sso_session_bound_to_different_org(self, mock_org_cls): + """Session was SSO-authenticated for a different org — must not grant + access to this org.""" + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info( + session_auth_method="sso", session_org_id="different-org" + ) + + with self.assertRaises(SSORequiredError): + mw.resolve(self._next, None, info, organisation_id="org-1") + + @patch("backend.graphene.middleware.Organisation") + def test_blocks_password_session_against_enforced_org(self, mock_org_cls): + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + with self.assertRaises(SSORequiredError) as cm: + mw.resolve(self._next, None, info, organisation_id="org-1") + + self.assertEqual(cm.exception.extensions["code"], "SSO_REQUIRED") + self.assertEqual(cm.exception.extensions["organisation_id"], "org-1") + + @patch("backend.graphene.middleware.Organisation") + def test_blocks_session_with_no_auth_method(self, mock_org_cls): + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method=None) + + with self.assertRaises(SSORequiredError): + mw.resolve(self._next, None, info, organisation_id="org-1") + + def test_unauthenticated_user_passes_through(self): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(user_authenticated=False) + + result = mw.resolve(self._next, None, info, organisation_id="org-1") + self.assertEqual(result, "resolver_ran") + + def test_no_org_id_passes_through(self): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + # Resolver with no org-scoped kwargs — e.g. `organisations` list + result = mw.resolve(self._next, None, info) + self.assertEqual(result, "resolver_ran") + + @patch("backend.graphene.middleware.App") + @patch("backend.graphene.middleware.Organisation") + def test_resolves_org_via_app_id(self, mock_org_cls, mock_app_cls): + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + app = MagicMock(organisation_id="org-1") + mock_app_cls.objects.only.return_value.get.return_value = app + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + with self.assertRaises(SSORequiredError): + mw.resolve(self._next, None, info, app_id="app-1") + + @patch("backend.graphene.middleware.App") + @patch("backend.graphene.middleware.Environment") + @patch("backend.graphene.middleware.Organisation") + def test_resolves_org_via_env_id( + self, mock_org_cls, mock_env_cls, mock_app_cls + ): + from backend.graphene.middleware import ( + OrgSSOEnforcementMiddleware, + SSORequiredError, + ) + + env = MagicMock(app_id="app-1") + mock_env_cls.objects.only.return_value.get.return_value = env + + app = MagicMock(organisation_id="org-1") + mock_app_cls.objects.only.return_value.get.return_value = app + + org = MagicMock(require_sso=True, name="acme") + org.id = "org-1" + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + with self.assertRaises(SSORequiredError): + mw.resolve(self._next, None, info, env_id="env-1") + + def test_nonexistent_org_passes_through(self): + """If the org can't be loaded, don't block — let the resolver decide.""" + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + from api.models import Organisation + + with patch("backend.graphene.middleware.Organisation") as mock_org_cls: + # Preserve the real DoesNotExist so the middleware's except clause matches + mock_org_cls.DoesNotExist = Organisation.DoesNotExist + mock_org_cls.objects.only.return_value.get.side_effect = ( + Organisation.DoesNotExist + ) + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info(session_auth_method="password") + + result = mw.resolve(self._next, None, info, organisation_id="missing") + self.assertEqual(result, "resolver_ran") + + +# --------------------------------------------------------------------------- +# Session marker propagation (SSO callback) +# --------------------------------------------------------------------------- + +class OrgSSOEnforcementMiddlewareCacheTest(unittest.TestCase): + """The middleware caches org / app / env → org lookups per request so a + complex GraphQL document doesn't re-hit the DB for every resolver.""" + + class _StubRequest: + """Plain object so attribute gets return the default (MagicMock would + auto-create a new Mock for missing attrs, defeating the cache check).""" + + def __init__(self): + self.user = type("U", (), {"is_authenticated": True})() + self.session = {"auth_method": "sso", "auth_sso_org_id": "org-1"} + + def _make_info_with_real_request(self): + info = MagicMock() + info.context = self._StubRequest() + return info + + def _next(self, root, info, **kwargs): + return "ran" + + @patch("backend.graphene.middleware.Organisation") + def test_org_lookup_cached_across_calls(self, mock_org_cls): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + org = MagicMock(require_sso=True, name="acme") + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info_with_real_request() + + # Simulate multiple resolvers in the same request all touching org-1 + mw.resolve(self._next, None, info, organisation_id="org-1") + mw.resolve(self._next, None, info, organisation_id="org-1") + mw.resolve(self._next, None, info, organisation_id="org-1") + + # Org should only be fetched once — subsequent hits come from the + # per-request cache attached to info.context. + self.assertEqual( + mock_org_cls.objects.only.return_value.get.call_count, 1 + ) + + @patch("backend.graphene.middleware.App") + @patch("backend.graphene.middleware.Organisation") + def test_app_to_org_lookup_cached(self, mock_org_cls, mock_app_cls): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + app = MagicMock(organisation_id="org-1") + mock_app_cls.objects.only.return_value.get.return_value = app + org = MagicMock(require_sso=False, name="acme") + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info_with_real_request() + + mw.resolve(self._next, None, info, app_id="app-1") + mw.resolve(self._next, None, info, app_id="app-1") + + # App should resolve its org only once + self.assertEqual( + mock_app_cls.objects.only.return_value.get.call_count, 1 + ) + + @patch("backend.graphene.middleware.App") + @patch("backend.graphene.middleware.Environment") + @patch("backend.graphene.middleware.Organisation") + def test_env_to_org_lookup_cached( + self, mock_org_cls, mock_env_cls, mock_app_cls + ): + from backend.graphene.middleware import OrgSSOEnforcementMiddleware + + env = MagicMock(app_id="app-1") + mock_env_cls.objects.only.return_value.get.return_value = env + app = MagicMock(organisation_id="org-1") + mock_app_cls.objects.only.return_value.get.return_value = app + org = MagicMock(require_sso=False, name="acme") + mock_org_cls.objects.only.return_value.get.return_value = org + + mw = OrgSSOEnforcementMiddleware() + info = self._make_info_with_real_request() + + mw.resolve(self._next, None, info, env_id="env-1") + mw.resolve(self._next, None, info, env_id="env-1") + + self.assertEqual( + mock_env_cls.objects.only.return_value.get.call_count, 1 + ) + # App lookup was also cached so Env→App→Org isn't re-queried. + self.assertEqual( + mock_app_cls.objects.only.return_value.get.call_count, 1 + ) + + +class SSOSessionMarkersTest(unittest.TestCase): + """The SSO callback must tag the session with auth_method, auth_sso_org_id, + and auth_sso_provider_id so the middleware and future per-org gates have + everything they need.""" + + def test_password_login_clears_all_sso_markers(self): + from django.test import RequestFactory + + # Start with a request that had SSO markers (simulating a prior SSO session) + request = _make_post("/auth/password/login/", {"email": "a@b.com"}) + request.session["auth_method"] = "sso" + request.session["auth_sso_org_id"] = "org-1" + request.session["auth_sso_provider_id"] = "prov-1" + request.session.save() + + # Simulate the lines from password_login after successful auth + # (direct assertion on the logic at auth_password.py:273-276) + request.session["auth_method"] = "password" + request.session.pop("auth_sso_org_id", None) + request.session.pop("auth_sso_provider_id", None) + + self.assertEqual(request.session["auth_method"], "password") + self.assertNotIn("auth_sso_org_id", request.session) + self.assertNotIn("auth_sso_provider_id", request.session) + + +class PasswordChangeSessionMarkerPreservationTest(unittest.TestCase): + """Regression: Django's auth.login() calls session.flush() when the + stored HASH_SESSION_KEY doesn't match user.get_session_auth_hash() — + which happens every time set_password() runs, because the hash is + derived from the password. Without the manual save/restore around + login() in password_change, the SSO session markers get wiped and + the middleware starts blocking the user on the next request.""" + + def test_django_login_flushes_session_when_auth_hash_changes(self): + """Documents the Django behavior that makes the save/restore in + password_change necessary. Avoids the DB by mocking just the + bits of the user that login() looks at.""" + from django.contrib.auth import login, SESSION_KEY, HASH_SESSION_KEY + + request = _make_post("/auth/password/change/", {}) + # Seed the session as if a user is logged in with SSO markers. + request.session[SESSION_KEY] = "user-42" + request.session[HASH_SESSION_KEY] = "old-auth-hash" + request.session["auth_method"] = "sso" + request.session["auth_sso_org_id"] = "org-1" + request.session["auth_sso_provider_id"] = "prov-1" + request.session.save() + + # Simulate the user after set_password: same pk, different hash. + user = MagicMock() + user.pk = "user-42" + user._meta.pk.value_to_string.return_value = "user-42" + user.get_session_auth_hash.return_value = "new-auth-hash" + user.backend = "django.contrib.auth.backends.ModelBackend" + user.is_authenticated = True + + login(request, user) + + # Session was flushed — all non-Django keys are gone. The view + # compensates by snapshotting and re-writing the markers around + # this call. + self.assertNotIn("auth_method", request.session) + self.assertNotIn("auth_sso_org_id", request.session) + self.assertNotIn("auth_sso_provider_id", request.session) + + +# --------------------------------------------------------------------------- +# Security review — config validation, SSRF guard, lockout prevention +# --------------------------------------------------------------------------- + +class ConfigValidationTest(unittest.TestCase): + """validate_provider_config — required-field, shape, and sealed-secret checks.""" + + def test_entra_id_requires_tenant_client_secret(self): + from api.utils.sso import validate_provider_config + + with self.assertRaisesRegex(ValueError, "tenant_id"): + validate_provider_config("entra_id", {"client_id": "x"}) + + def test_entra_id_rejects_non_uuid_tenant(self): + from api.utils.sso import validate_provider_config + + with self.assertRaisesRegex(ValueError, "tenant_id"): + validate_provider_config( + "entra_id", + { + "tenant_id": "not-a-uuid", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "ph:v1:a:b", + }, + ) + + def test_entra_id_rejects_plaintext_secret(self): + from api.utils.sso import validate_provider_config + + with self.assertRaisesRegex(ValueError, "encrypted client-side"): + validate_provider_config( + "entra_id", + { + "tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "plaintext-not-sealed", + }, + ) + + def test_entra_id_accepts_valid_config(self): + from api.utils.sso import validate_provider_config + + # Should not raise + validate_provider_config( + "entra_id", + { + "tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + "client_secret": "ph:v1:publickey:ciphertext", + }, + ) + + def test_okta_requires_https_issuer(self): + from api.utils.sso import validate_provider_config + + with self.assertRaisesRegex(ValueError, "issuer"): + validate_provider_config( + "okta", + { + "issuer": "http://dev-12345.okta.com", # http not https + "client_id": "0oaxxx", + "client_secret": "ph:v1:a:b", + }, + ) + + def test_okta_accepts_valid_config(self): + from api.utils.sso import validate_provider_config + + validate_provider_config( + "okta", + { + "issuer": "https://dev-12345.okta.com", + "client_id": "0oaxxx", + "client_secret": "ph:v1:pk:ct", + }, + ) + + def test_unknown_provider_rejected(self): + from api.utils.sso import validate_provider_config + + with self.assertRaisesRegex(ValueError, "Unsupported provider type"): + validate_provider_config("made_up", {}) + + def test_require_secret_false_skips_secret(self): + """On update with blank secret, require_secret=False permits missing/blank.""" + from api.utils.sso import validate_provider_config + + # Update with only the non-secret fields — secret keeps existing + validate_provider_config( + "entra_id", + { + "tenant_id": "72f988bf-86f1-41af-91ab-2d7cd011db47", + "client_id": "6731de76-14a6-49ae-97bc-6eba6914391e", + }, + require_secret=False, + ) + + +class UpdateSSOProviderDeactivationLockoutTest(unittest.TestCase): + """When the only active provider is deactivated while SSO enforcement + is on, the mutation must auto-disable require_sso — otherwise no one + (including the admin) can authenticate on their next request.""" + + @patch("backend.graphene.mutations.sso.OrganisationMember") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_deactivating_only_provider_disables_enforcement( + self, mock_perm, mock_provider_cls, mock_member_cls + ): + from backend.graphene.mutations.sso import UpdateOrganisationSSOProviderMutation + + org = MagicMock() + org.plan = "EN" + org.require_sso = True + + provider = MagicMock() + provider.enabled = True # currently-active + provider.organisation = org + provider.provider_type = "entra_id" + mock_provider_cls.objects.get.return_value = provider + # No other enabled providers + mock_provider_cls.objects.filter.return_value.exclude.return_value.exists.return_value = False + + with patch( + "backend.graphene.mutations.sso.Organisation.ENTERPRISE_PLAN", "EN" + ): + info = MagicMock() + info.context.user = MagicMock() + UpdateOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1", enabled=False + ) + + self.assertFalse(org.require_sso) + org.save.assert_called() + + @patch("backend.graphene.mutations.sso.OrganisationMember") + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_deactivating_when_other_active_providers_exist_keeps_enforcement( + self, mock_perm, mock_provider_cls, mock_member_cls + ): + """If another enabled provider exists, enforcement stays on.""" + from backend.graphene.mutations.sso import UpdateOrganisationSSOProviderMutation + + org = MagicMock() + org.plan = "EN" + org.require_sso = True + + provider = MagicMock() + provider.enabled = True + provider.organisation = org + provider.provider_type = "entra_id" + mock_provider_cls.objects.get.return_value = provider + # Another enabled provider exists + mock_provider_cls.objects.filter.return_value.exclude.return_value.exists.return_value = True + + with patch( + "backend.graphene.mutations.sso.Organisation.ENTERPRISE_PLAN", "EN" + ): + info = MagicMock() + info.context.user = MagicMock() + UpdateOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1", enabled=False + ) + + self.assertTrue(org.require_sso) + + +class TestSSOSSRFGuardTest(unittest.TestCase): + """TestOrganisationSSOProviderMutation must refuse to fetch from + unsafe issuer URLs on cloud deployments. Self-hosted skips the + check (admins may legitimately run internal IdPs) — matching the + pattern in vault/gitlab/nomad/github-actions/aws-iam integrations. + """ + + @patch("backend.graphene.mutations.sso.CLOUD_HOSTED", True) + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_cloud_blocks_metadata_issuer(self, mock_perm, mock_provider_cls): + from backend.graphene.mutations.sso import TestOrganisationSSOProviderMutation + + provider = MagicMock() + provider.provider_type = "okta" + provider.config = { + "issuer": "https://169.254.169.254", + "client_id": "x", + "client_secret": "y", + } + mock_provider_cls.objects.get.return_value = provider + + info = MagicMock() + info.context.user = MagicMock() + result = TestOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1" + ) + self.assertFalse(result.success) + self.assertIn("valid public HTTPS", result.error) + + @patch("backend.graphene.mutations.sso.CLOUD_HOSTED", True) + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_cloud_blocks_private_rfc1918_issuer(self, mock_perm, mock_provider_cls): + from backend.graphene.mutations.sso import TestOrganisationSSOProviderMutation + + provider = MagicMock() + provider.provider_type = "okta" + provider.config = { + "issuer": "https://10.0.0.1", + "client_id": "x", + "client_secret": "y", + } + mock_provider_cls.objects.get.return_value = provider + + info = MagicMock() + info.context.user = MagicMock() + result = TestOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1" + ) + self.assertFalse(result.success) + + @patch("backend.graphene.mutations.sso.CLOUD_HOSTED", False) + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_self_hosted_skips_ip_check(self, mock_perm, mock_provider_cls): + """Self-hosted admin with an internal IdP should be allowed to test.""" + import requests as http_requests + from backend.graphene.mutations.sso import TestOrganisationSSOProviderMutation + + provider = MagicMock() + provider.provider_type = "okta" + provider.config = { + "issuer": "https://10.0.0.50", + "client_id": "x", + "client_secret": "y", + } + mock_provider_cls.objects.get.return_value = provider + + fake_resp = MagicMock() + fake_resp.json.return_value = { + "authorization_endpoint": "https://10.0.0.50/authorize", + "token_endpoint": "https://10.0.0.50/token", + } + fake_resp.raise_for_status.return_value = None + + info = MagicMock() + info.context.user = MagicMock() + with patch.object(http_requests, "get", return_value=fake_resp): + result = TestOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1" + ) + self.assertTrue(result.success) + + @patch("backend.graphene.mutations.sso.CLOUD_HOSTED", True) + @patch("backend.graphene.mutations.sso.OrganisationSSOProvider") + @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) + def test_returns_generic_error_on_upstream_failure( + self, mock_perm, mock_provider_cls + ): + """Never surface upstream response bodies / error details to the caller.""" + import requests as http_requests + from backend.graphene.mutations.sso import TestOrganisationSSOProviderMutation + + provider = MagicMock() + provider.provider_type = "okta" + provider.config = { + "issuer": "https://okta.com", + "client_id": "x", + "client_secret": "y", + } + mock_provider_cls.objects.get.return_value = provider + + # Let validate_url_is_safe pass (public IP), but the HTTP fetch fails + # with a juicy error body we must NOT surface to the caller. + with patch( + "api.utils.network.socket.gethostbyname_ex", + return_value=("okta.com", [], ["1.1.1.1"]), + ), patch.object( + http_requests, + "get", + side_effect=Exception("INTERNAL SECRET"), + ): + info = MagicMock() + info.context.user = MagicMock() + result = TestOrganisationSSOProviderMutation.mutate( + None, info, provider_id="p1" + ) + + self.assertFalse(result.success) + self.assertNotIn("INTERNAL SECRET", result.error) + self.assertIn("Failed to reach", result.error) + + +if __name__ == "__main__": + unittest.main() From 2893f9bf93048b69b48001c9aca66a520bc4ae88 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:21:28 +0800 Subject: [PATCH 050/100] feat(frontend): add GraphQL queries, mutations, and generated types for org SSO - Five mutations: createOrgSSOProvider, updateOrgSSOProvider, deleteOrgSSOProvider, testOrgSSOProvider, updateOrgSecurity - getOrgSSOProviders query for the settings page - getOrganisations extended with requireSso + ssoProviders so the lobby, access tabs, and session context all see the same snapshot - apollo/schema.graphql + codegen outputs regenerated --- frontend/apollo/fragment-masking.ts | 27 ++- frontend/apollo/gql.ts | 206 +++++++++++++++++- frontend/apollo/graphql.ts | 161 +++++++++++++- frontend/apollo/schema.graphql | 86 ++++++-- .../mutations/sso/createOrgSSOProvider.gql | 15 ++ .../mutations/sso/deleteOrgSSOProvider.gql | 5 + .../mutations/sso/testOrgSSOProvider.gql | 6 + .../mutations/sso/updateOrgSSOProvider.gql | 15 ++ .../mutations/sso/updateOrgSecurity.gql | 6 + frontend/graphql/queries/getOrganisations.gql | 6 + .../queries/sso/getOrgSSOProviders.gql | 27 +++ 11 files changed, 523 insertions(+), 37 deletions(-) create mode 100644 frontend/graphql/mutations/sso/createOrgSSOProvider.gql create mode 100644 frontend/graphql/mutations/sso/deleteOrgSSOProvider.gql create mode 100644 frontend/graphql/mutations/sso/testOrgSSOProvider.gql create mode 100644 frontend/graphql/mutations/sso/updateOrgSSOProvider.gql create mode 100644 frontend/graphql/mutations/sso/updateOrgSecurity.gql create mode 100644 frontend/graphql/queries/sso/getOrgSSOProviders.gql diff --git a/frontend/apollo/fragment-masking.ts b/frontend/apollo/fragment-masking.ts index 2ba06f10b..aca71b135 100644 --- a/frontend/apollo/fragment-masking.ts +++ b/frontend/apollo/fragment-masking.ts @@ -1,3 +1,4 @@ +/* eslint-disable */ import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; import { FragmentDefinitionNode } from 'graphql'; import { Incremental } from './graphql'; @@ -19,25 +20,45 @@ export function useFragment( _documentNode: DocumentTypeDecoration, fragmentType: FragmentType> ): TType; +// return nullable if `fragmentType` is undefined +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | undefined +): TType | undefined; // return nullable if `fragmentType` is nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: FragmentType> | null +): TType | null; +// return nullable if `fragmentType` is nullable or undefined export function useFragment( _documentNode: DocumentTypeDecoration, fragmentType: FragmentType> | null | undefined ): TType | null | undefined; // return array of non-nullable if `fragmentType` is array of non-nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> +): Array; +// return array of nullable if `fragmentType` is array of nullable +export function useFragment( + _documentNode: DocumentTypeDecoration, + fragmentType: Array>> | null | undefined +): Array | null | undefined; +// return readonly array of non-nullable if `fragmentType` is array of non-nullable export function useFragment( _documentNode: DocumentTypeDecoration, fragmentType: ReadonlyArray>> ): ReadonlyArray; -// return array of nullable if `fragmentType` is array of nullable +// return readonly array of nullable if `fragmentType` is array of nullable export function useFragment( _documentNode: DocumentTypeDecoration, fragmentType: ReadonlyArray>> | null | undefined ): ReadonlyArray | null | undefined; export function useFragment( _documentNode: DocumentTypeDecoration, - fragmentType: FragmentType> | ReadonlyArray>> | null | undefined -): TType | ReadonlyArray | null | undefined { + fragmentType: FragmentType> | Array>> | ReadonlyArray>> | null | undefined +): TType | Array | ReadonlyArray | null | undefined { return fragmentType as any; } diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 250f7f31c..1039c1a61 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -11,8 +11,176 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/ * 3. It does not support dead code elimination, so it will add unused operations. * * Therefore it is highly recommended to use the babel or swc plugin for production. - */ -const documents = { + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + "mutation CreateAccessPolicy($name: String!, $allowedIps: String!, $isGlobal: Boolean!, $organisationId: ID!) {\n createNetworkAccessPolicy(\n name: $name\n allowedIps: $allowedIps\n isGlobal: $isGlobal\n organisationId: $organisationId\n ) {\n networkAccessPolicy {\n id\n }\n }\n}": typeof types.CreateAccessPolicyDocument, + "mutation CreateRole($name: String!, $description: String!, $color: String!, $permissions: JSONString!, $organisationId: ID!) {\n createCustomRole(\n name: $name\n description: $description\n color: $color\n permissions: $permissions\n organisationId: $organisationId\n ) {\n role {\n id\n }\n }\n}": typeof types.CreateRoleDocument, + "mutation DeleteAccessPolicy($id: ID!) {\n deleteNetworkAccessPolicy(id: $id) {\n ok\n }\n}": typeof types.DeleteAccessPolicyDocument, + "mutation DeleteRole($id: ID!) {\n deleteCustomRole(id: $id) {\n ok\n }\n}": typeof types.DeleteRoleDocument, + "mutation UpdateAccountNetworkPolicy($accounts: [AccountPolicyInput], $organisationId: ID!) {\n updateAccountNetworkAccessPolicies(\n accountInputs: $accounts\n organisationId: $organisationId\n ) {\n ok\n }\n}": typeof types.UpdateAccountNetworkPolicyDocument, + "mutation UpdateAccessPolicies($inputs: [UpdatePolicyInput]) {\n updateNetworkAccessPolicy(policyInputs: $inputs) {\n networkAccessPolicy {\n id\n }\n }\n}": typeof types.UpdateAccessPoliciesDocument, + "mutation UpdateRole($id: ID!, $name: String!, $description: String!, $color: String!, $permissions: JSONString!) {\n updateCustomRole(\n id: $id\n name: $name\n description: $description\n color: $color\n permissions: $permissions\n ) {\n role {\n id\n }\n }\n}": typeof types.UpdateRoleDocument, + "mutation AddMemberToApp($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n addAppMember(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": typeof types.AddMemberToAppDocument, + "mutation BulkAddMembersToApp($appId: ID!, $members: [AppMemberInputType!]!) {\n bulkAddAppMembers(appId: $appId, members: $members) {\n app {\n id\n }\n }\n}": typeof types.BulkAddMembersToAppDocument, + "mutation RemoveMemberFromApp($memberId: ID!, $memberType: MemberType, $appId: ID!) {\n removeAppMember(memberId: $memberId, memberType: $memberType, appId: $appId) {\n app {\n id\n }\n }\n}": typeof types.RemoveMemberFromAppDocument, + "mutation UpdateAppInfoOp($id: ID!, $name: String, $description: String) {\n updateAppInfo(id: $id, name: $name, description: $description) {\n app {\n id\n name\n description\n }\n }\n}": typeof types.UpdateAppInfoOpDocument, + "mutation UpdateEnvScope($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": typeof types.UpdateEnvScopeDocument, + "mutation CancelStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n cancelSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n }\n}": typeof types.CancelStripeSubscriptionDocument, + "mutation CreateStripeSetupIntentOp($organisationId: ID!) {\n createSetupIntent(organisationId: $organisationId) {\n clientSecret\n }\n}": typeof types.CreateStripeSetupIntentOpDocument, + "mutation DeleteStripePaymentMethod($organisationId: ID!, $paymentMethodId: String!) {\n deletePaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": typeof types.DeleteStripePaymentMethodDocument, + "mutation InitStripeUpgradeCheckout($organisationId: ID!, $planType: PlanTypeEnum!, $billingPeriod: BillingPeriodEnum!) {\n createSubscriptionCheckoutSession(\n organisationId: $organisationId\n planType: $planType\n billingPeriod: $billingPeriod\n ) {\n clientSecret\n }\n}": typeof types.InitStripeUpgradeCheckoutDocument, + "mutation MigratePricingOp($organisationId: ID!) {\n migratePricing(organisationId: $organisationId) {\n success\n message\n }\n}": typeof types.MigratePricingOpDocument, + "mutation ModifyStripeSubscription($organisationId: ID!, $subscriptionId: String!, $planType: PlanTypeEnum!, $billingPeriod: BillingPeriodEnum!) {\n modifySubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n planType: $planType\n billingPeriod: $billingPeriod\n ) {\n success\n message\n status\n }\n}": typeof types.ModifyStripeSubscriptionDocument, + "mutation ResumeStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n resumeSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n message\n cancelledAt\n status\n }\n}": typeof types.ResumeStripeSubscriptionDocument, + "mutation SetDefaultStripePaymentMethodOp($organisationId: ID!, $paymentMethodId: String!) {\n setDefaultPaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": typeof types.SetDefaultStripePaymentMethodOpDocument, + "mutation CreateApplication($id: ID!, $organisationId: ID!, $name: String!, $identityKey: String!, $appToken: String!, $appSeed: String!, $wrappedKeyShare: String!, $appVersion: Int!) {\n createApp(\n id: $id\n organisationId: $organisationId\n name: $name\n identityKey: $identityKey\n appToken: $appToken\n appSeed: $appSeed\n wrappedKeyShare: $wrappedKeyShare\n appVersion: $appVersion\n ) {\n app {\n id\n name\n identityKey\n }\n }\n}": typeof types.CreateApplicationDocument, + "mutation CreateOrg($id: ID!, $name: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n createOrganisation(\n id: $id\n name: $name\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n organisation {\n id\n name\n memberId\n }\n }\n}": typeof types.CreateOrgDocument, + "mutation DeleteApplication($id: ID!) {\n deleteApp(id: $id) {\n ok\n }\n}": typeof types.DeleteApplicationDocument, + "mutation BulkProcessSecrets($secretsToCreate: [SecretInput!]!, $secretsToUpdate: [SecretInput!]!, $secretsToDelete: [ID!]!) {\n createSecrets(secretsData: $secretsToCreate) {\n secrets {\n id\n }\n }\n editSecrets(secretsData: $secretsToUpdate) {\n secrets {\n id\n }\n }\n deleteSecrets(ids: $secretsToDelete) {\n secrets {\n id\n }\n }\n}": typeof types.BulkProcessSecretsDocument, + "mutation CreateEnv($envInput: EnvironmentInput!, $adminKeys: [EnvironmentKeyInput], $wrappedSeed: String, $wrappedSalt: String) {\n createEnvironment(\n environmentData: $envInput\n adminKeys: $adminKeys\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}": typeof types.CreateEnvDocument, + "mutation CreateEnvKey($envId: ID!, $userId: ID, $wrappedSeed: String!, $wrappedSalt: String!, $identityKey: String!) {\n createEnvironmentKey(\n envId: $envId\n userId: $userId\n wrappedSeed: $wrappedSeed\n wrappedSalt: $wrappedSalt\n identityKey: $identityKey\n ) {\n environmentKey {\n id\n createdAt\n }\n }\n}": typeof types.CreateEnvKeyDocument, + "mutation CreateEnvToken($envId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!) {\n createEnvironmentToken(\n envId: $envId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n ) {\n environmentToken {\n id\n createdAt\n }\n }\n}": typeof types.CreateEnvTokenDocument, + "mutation CreateNewSecretFolder($envId: ID!, $name: String!, $path: String!) {\n createSecretFolder(envId: $envId, name: $name, path: $path) {\n folder {\n id\n name\n path\n }\n }\n}": typeof types.CreateNewSecretFolderDocument, + "mutation CreateNewPersonalSecret($newPersonalSecret: PersonalSecretInput!) {\n createOverride(overrideData: $newPersonalSecret) {\n override {\n id\n secret {\n id\n }\n value\n isActive\n createdAt\n }\n }\n}": typeof types.CreateNewPersonalSecretDocument, + "mutation CreateNewSecret($newSecret: SecretInput!) {\n createSecret(secretData: $newSecret) {\n secret {\n id\n key\n value\n createdAt\n }\n }\n}": typeof types.CreateNewSecretDocument, + "mutation CreateNewSecretTag($orgId: ID!, $name: String!, $color: String!) {\n createSecretTag(orgId: $orgId, name: $name, color: $color) {\n tag {\n id\n }\n }\n}": typeof types.CreateNewSecretTagDocument, + "mutation CreateNewServiceToken($appId: ID!, $environmentKeys: [EnvironmentKeyInput], $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $name: String!, $expiry: BigInt) {\n createServiceToken(\n appId: $appId\n environmentKeys: $environmentKeys\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n name: $name\n expiry: $expiry\n ) {\n serviceToken {\n id\n createdAt\n expiresAt\n }\n }\n}": typeof types.CreateNewServiceTokenDocument, + "mutation DeleteEnv($environmentId: ID!) {\n deleteEnvironment(environmentId: $environmentId) {\n ok\n }\n}": typeof types.DeleteEnvDocument, + "mutation DeleteFolder($folderId: ID!) {\n deleteSecretFolder(folderId: $folderId) {\n ok\n }\n}": typeof types.DeleteFolderDocument, + "mutation DeleteSecretOp($id: ID!) {\n deleteSecret(id: $id) {\n secret {\n id\n }\n }\n}": typeof types.DeleteSecretOpDocument, + "mutation RevokeServiceToken($tokenId: ID!) {\n deleteServiceToken(tokenId: $tokenId) {\n ok\n }\n}": typeof types.RevokeServiceTokenDocument, + "mutation UpdateSecret($id: ID!, $secretData: SecretInput!) {\n editSecret(id: $id, secretData: $secretData) {\n secret {\n id\n updatedAt\n }\n }\n}": typeof types.UpdateSecretDocument, + "mutation InitAppEnvironments($devEnv: EnvironmentInput!, $stagingEnv: EnvironmentInput!, $prodEnv: EnvironmentInput!, $devAdminKeys: [EnvironmentKeyInput], $stagAdminKeys: [EnvironmentKeyInput], $prodAdminKeys: [EnvironmentKeyInput]) {\n devEnvironment: createEnvironment(\n environmentData: $devEnv\n adminKeys: $devAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n stagingEnvironment: createEnvironment(\n environmentData: $stagingEnv\n adminKeys: $stagAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n prodEnvironment: createEnvironment(\n environmentData: $prodEnv\n adminKeys: $prodAdminKeys\n ) {\n environment {\n id\n name\n createdAt\n identityKey\n }\n }\n}": typeof types.InitAppEnvironmentsDocument, + "mutation LogSecretReads($ids: [ID]!) {\n readSecret(ids: $ids) {\n ok\n }\n}": typeof types.LogSecretReadsDocument, + "mutation RemovePersonalSecret($secretId: ID!) {\n removeOverride(secretId: $secretId) {\n ok\n }\n}": typeof types.RemovePersonalSecretDocument, + "mutation RenameEnv($environmentId: ID!, $name: String!) {\n renameEnvironment(environmentId: $environmentId, name: $name) {\n environment {\n id\n name\n updatedAt\n }\n }\n}": typeof types.RenameEnvDocument, + "mutation CreateNewAWSDynamicSecret($organisationId: ID!, $environmentId: ID!, $path: String, $name: String!, $description: String, $defaultTtl: Int, $maxTtl: Int, $authenticationId: ID, $config: AWSConfigInput!, $keyMap: [KeyMapInput]!) {\n createAwsDynamicSecret(\n organisationId: $organisationId\n environmentId: $environmentId\n path: $path\n name: $name\n description: $description\n defaultTtl: $defaultTtl\n maxTtl: $maxTtl\n authenticationId: $authenticationId\n config: $config\n keyMap: $keyMap\n ) {\n dynamicSecret {\n id\n name\n description\n provider\n createdAt\n updatedAt\n }\n }\n}": typeof types.CreateNewAwsDynamicSecretDocument, + "mutation CreateDynamicSecretLease($secretId: ID!, $ttl: Int!, $name: String!) {\n createDynamicSecretLease(secretId: $secretId, ttl: $ttl, name: $name) {\n lease {\n id\n name\n credentials {\n ... on AwsCredentialsType {\n accessKeyId\n secretAccessKey\n username\n }\n }\n expiresAt\n }\n }\n}": typeof types.CreateDynamicSecretLeaseDocument, + "mutation DeleteDynamicSecretOP($secretId: ID!) {\n deleteDynamicSecret(secretId: $secretId) {\n ok\n }\n}": typeof types.DeleteDynamicSecretOpDocument, + "mutation RenewDynamicSecretLeaseOP($leaseId: ID!, $ttl: Int!) {\n renewDynamicSecretLease(leaseId: $leaseId, ttl: $ttl) {\n lease {\n id\n name\n expiresAt\n status\n }\n }\n}": typeof types.RenewDynamicSecretLeaseOpDocument, + "mutation RevokeDynamicSecretLeaseOP($leaseId: ID!) {\n revokeDynamicSecretLease(leaseId: $leaseId) {\n lease {\n id\n name\n expiresAt\n revokedAt\n status\n }\n }\n}": typeof types.RevokeDynamicSecretLeaseOpDocument, + "mutation UpdateDynamicSecret($dynamicSecretId: ID!, $organisationId: ID!, $path: String, $name: String!, $description: String, $defaultTtl: Int, $maxTtl: Int, $authenticationId: ID, $config: AWSConfigInput!, $keyMap: [KeyMapInput]!) {\n updateAwsDynamicSecret(\n organisationId: $organisationId\n dynamicSecretId: $dynamicSecretId\n path: $path\n name: $name\n description: $description\n defaultTtl: $defaultTtl\n maxTtl: $maxTtl\n authenticationId: $authenticationId\n config: $config\n keyMap: $keyMap\n ) {\n dynamicSecret {\n id\n name\n description\n provider\n createdAt\n updatedAt\n }\n }\n}": typeof types.UpdateDynamicSecretDocument, + "mutation CreateSharedSecret($input: LockboxInput!) {\n createLockbox(input: $input) {\n lockbox {\n id\n allowedViews\n expiresAt\n }\n }\n}": typeof types.CreateSharedSecretDocument, + "mutation UpdateEnvOrder($appId: ID!, $environmentOrder: [ID]!) {\n updateEnvironmentOrder(appId: $appId, environmentOrder: $environmentOrder) {\n ok\n }\n}": typeof types.UpdateEnvOrderDocument, + "mutation CreateExtIdentity($organisationId: ID!, $provider: String!, $name: String!, $description: String, $trustedPrincipals: String!, $signatureTtlSeconds: Int, $stsEndpoint: String, $tenantId: String, $resource: String, $tokenNamePattern: String, $defaultTtlSeconds: Int!, $maxTtlSeconds: Int!) {\n createIdentity(\n organisationId: $organisationId\n provider: $provider\n name: $name\n description: $description\n trustedPrincipals: $trustedPrincipals\n signatureTtlSeconds: $signatureTtlSeconds\n stsEndpoint: $stsEndpoint\n tenantId: $tenantId\n resource: $resource\n tokenNamePattern: $tokenNamePattern\n defaultTtlSeconds: $defaultTtlSeconds\n maxTtlSeconds: $maxTtlSeconds\n ) {\n identity {\n id\n provider\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n }\n }\n}": typeof types.CreateExtIdentityDocument, + "mutation DeleteExtIdentity($id: ID!) {\n deleteIdentity(id: $id) {\n ok\n }\n}": typeof types.DeleteExtIdentityDocument, + "mutation UpdateExtIdentity($id: ID!, $name: String, $description: String, $trustedPrincipals: String, $signatureTtlSeconds: Int, $stsEndpoint: String, $tenantId: String, $resource: String, $tokenNamePattern: String, $defaultTtlSeconds: Int, $maxTtlSeconds: Int) {\n updateIdentity(\n id: $id\n name: $name\n description: $description\n trustedPrincipals: $trustedPrincipals\n signatureTtlSeconds: $signatureTtlSeconds\n stsEndpoint: $stsEndpoint\n tenantId: $tenantId\n resource: $resource\n tokenNamePattern: $tokenNamePattern\n defaultTtlSeconds: $defaultTtlSeconds\n maxTtlSeconds: $maxTtlSeconds\n ) {\n identity {\n id\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n }\n }\n}": typeof types.UpdateExtIdentityDocument, + "mutation AcceptOrganisationInvite($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!, $inviteId: ID!) {\n createOrganisationMember(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n inviteId: $inviteId\n ) {\n orgMember {\n id\n email\n createdAt\n role {\n name\n }\n }\n }\n}": typeof types.AcceptOrganisationInviteDocument, + "mutation BulkInviteMembers($orgId: ID!, $invites: [InviteInput!]!) {\n bulkInviteOrganisationMembers(orgId: $orgId, invites: $invites) {\n invites {\n id\n inviteeEmail\n expiresAt\n }\n }\n}": typeof types.BulkInviteMembersDocument, + "mutation DeleteOrgInvite($inviteId: ID!) {\n deleteInvitation(inviteId: $inviteId) {\n ok\n }\n}": typeof types.DeleteOrgInviteDocument, + "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": typeof types.RemoveMemberDocument, + "mutation TransferOrgOwnership($organisationId: ID!, $newOwnerId: ID!, $billingEmail: String) {\n transferOrganisationOwnership(\n organisationId: $organisationId\n newOwnerId: $newOwnerId\n billingEmail: $billingEmail\n ) {\n ok\n }\n}": typeof types.TransferOrgOwnershipDocument, + "mutation UpdateMemberRole($memberId: ID!, $roleId: ID!) {\n updateOrganisationMemberRole(memberId: $memberId, roleId: $roleId) {\n orgMember {\n id\n role {\n name\n }\n }\n }\n}": typeof types.UpdateMemberRoleDocument, + "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.UpdateWrappedSecretsDocument, + "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": typeof types.RotateAppKeyDocument, + "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}": typeof types.CreateServiceAccountOpDocument, + "mutation CreateSAToken($serviceAccountId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n token {\n id\n }\n }\n}": typeof types.CreateSaTokenDocument, + "mutation DeleteServiceAccountOp($id: ID!) {\n deleteServiceAccount(serviceAccountId: $id) {\n ok\n }\n}": typeof types.DeleteServiceAccountOpDocument, + "mutation DeleteServiceAccountTokenOp($id: ID!) {\n deleteServiceAccountToken(tokenId: $id) {\n ok\n }\n}": typeof types.DeleteServiceAccountTokenOpDocument, + "mutation EnableSAClientKeyManagement($serviceAccountId: ID!) {\n enableServiceAccountClientSideKeyManagement(serviceAccountId: $serviceAccountId) {\n serviceAccount {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n }\n }\n}": typeof types.EnableSaClientKeyManagementDocument, + "mutation EnableSAServerKeyManagement($serviceAccountId: ID!, $serverWrappedKeyring: String!, $serverWrappedRecovery: String!) {\n enableServiceAccountServerSideKeyManagement(\n serviceAccountId: $serviceAccountId\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n name\n serverSideKeyManagementEnabled\n }\n }\n}": typeof types.EnableSaServerKeyManagementDocument, + "mutation UpdateServiceAccountHandlerKeys($orgId: ID!, $handlers: [ServiceAccountHandlerInput]) {\n updateServiceAccountHandlers(organisationId: $orgId, handlers: $handlers) {\n ok\n }\n}": typeof types.UpdateServiceAccountHandlerKeysDocument, + "mutation UpdateServiceAccountOp($serviceAccountId: ID!, $name: String!, $roleId: ID!, $identityIds: [ID!]) {\n updateServiceAccount(\n serviceAccountId: $serviceAccountId\n name: $name\n roleId: $roleId\n identityIds: $identityIds\n ) {\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n permissions\n }\n identities {\n id\n name\n }\n }\n }\n}": typeof types.UpdateServiceAccountOpDocument, + "mutation CreateOrgSSOProvider($orgId: ID!, $providerType: String!, $name: String!, $config: JSONString!) {\n createOrganisationSsoProvider(\n orgId: $orgId\n providerType: $providerType\n name: $name\n config: $config\n ) {\n providerId\n }\n}": typeof types.CreateOrgSsoProviderDocument, + "mutation DeleteOrgSSOProvider($providerId: ID!) {\n deleteOrganisationSsoProvider(providerId: $providerId) {\n ok\n }\n}": typeof types.DeleteOrgSsoProviderDocument, + "mutation TestOrgSSOProvider($providerId: ID!) {\n testOrganisationSsoProvider(providerId: $providerId) {\n success\n error\n }\n}": typeof types.TestOrgSsoProviderDocument, + "mutation UpdateOrgSSOProvider($providerId: ID!, $name: String, $config: JSONString, $enabled: Boolean) {\n updateOrganisationSsoProvider(\n providerId: $providerId\n name: $name\n config: $config\n enabled: $enabled\n ) {\n ok\n }\n}": typeof types.UpdateOrgSsoProviderDocument, + "mutation UpdateOrgSecurity($orgId: ID!, $requireSso: Boolean!) {\n updateOrganisationSecurity(orgId: $orgId, requireSso: $requireSso) {\n ok\n sessionInvalidated\n }\n}": typeof types.UpdateOrgSecurityDocument, + "mutation CreateNewAWSSecretsSync($envId: ID!, $path: String!, $credentialId: ID!, $secretName: String!, $kmsId: String) {\n createAwsSecretSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n secretName: $secretName\n kmsId: $kmsId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewAwsSecretsSyncDocument, + "mutation CreateNewAzureKeyVaultSync($envId: ID!, $path: String!, $credentialId: ID!, $vaultUri: String!, $syncMode: String!, $secretName: String) {\n createAzureKeyVaultSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n vaultUri: $vaultUri\n syncMode: $syncMode\n secretName: $secretName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewAzureKeyVaultSyncDocument, + "mutation CreateNewCfPagesSync($envId: ID!, $path: String!, $projectName: String!, $deploymentId: ID!, $projectEnv: String!, $credentialId: ID!) {\n createCloudflarePagesSync(\n envId: $envId\n path: $path\n projectName: $projectName\n deploymentId: $deploymentId\n projectEnv: $projectEnv\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewCfPagesSyncDocument, + "mutation CreateNewCfWorkersSync($envId: ID!, $path: String!, $workerName: String!, $credentialId: ID!) {\n createCloudflareWorkersSync(\n envId: $envId\n path: $path\n workerName: $workerName\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewCfWorkersSyncDocument, + "mutation DeleteProviderCreds($credentialId: ID!) {\n deleteProviderCredentials(credentialId: $credentialId) {\n ok\n }\n}": typeof types.DeleteProviderCredsDocument, + "mutation DeleteSync($syncId: ID!) {\n deleteEnvSync(syncId: $syncId) {\n ok\n }\n}": typeof types.DeleteSyncDocument, + "mutation CreateNewGhActionsSync($envId: ID!, $path: String!, $repoName: String, $owner: String!, $credentialId: ID!, $environmentName: String, $orgSync: Boolean, $repoVisibility: String) {\n createGhActionsSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n environmentName: $environmentName\n orgSync: $orgSync\n repoVisibility: $repoVisibility\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewGhActionsSyncDocument, + "mutation CreateNewGhDependabotSync($envId: ID!, $path: String!, $repoName: String, $owner: String!, $credentialId: ID!, $orgSync: Boolean, $repoVisibility: String) {\n createGhDependabotSync(\n envId: $envId\n path: $path\n repoName: $repoName\n owner: $owner\n credentialId: $credentialId\n orgSync: $orgSync\n repoVisibility: $repoVisibility\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewGhDependabotSyncDocument, + "mutation CreateNewGitlabCiSync($envId: ID!, $path: String!, $credentialId: ID!, $resourcePath: String!, $resourceId: String!, $isGroup: Boolean!, $isMasked: Boolean!, $isProtected: Boolean!) {\n createGitlabCiSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n resourcePath: $resourcePath\n resourceId: $resourceId\n isGroup: $isGroup\n masked: $isMasked\n protected: $isProtected\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewGitlabCiSyncDocument, + "mutation InitAppSyncing($appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n initEnvSync(appId: $appId, envKeys: $envKeys) {\n app {\n id\n sseEnabled\n }\n }\n}": typeof types.InitAppSyncingDocument, + "mutation CreateNewNomadSync($envId: ID!, $path: String!, $nomadPath: String!, $nomadNamespace: String!, $credentialId: ID!) {\n createNomadSync(\n envId: $envId\n path: $path\n nomadPath: $nomadPath\n nomadNamespace: $nomadNamespace\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewNomadSyncDocument, + "mutation CreateNewRailwaySync($envId: ID!, $path: String!, $credentialId: ID!, $railwayProject: RailwayResourceInput!, $railwayEnvironment: RailwayResourceInput!, $railwayService: RailwayResourceInput) {\n createRailwaySync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n railwayProject: $railwayProject\n railwayEnvironment: $railwayEnvironment\n railwayService: $railwayService\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewRailwaySyncDocument, + "mutation CreateNewRenderServiceSync($envId: ID!, $path: String!, $credentialId: ID!, $resourceId: String!, $resourceName: String!, $resourceType: RenderResourceType!, $secretFileName: String) {\n createRenderSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n resourceId: $resourceId\n resourceName: $resourceName\n resourceType: $resourceType\n secretFileName: $secretFileName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewRenderServiceSyncDocument, + "mutation SaveNewProviderCreds($orgId: ID!, $provider: String!, $name: String!, $credentials: JSONString!) {\n createProviderCredentials(\n orgId: $orgId\n provider: $provider\n name: $name\n credentials: $credentials\n ) {\n credential {\n id\n }\n }\n}": typeof types.SaveNewProviderCredsDocument, + "mutation ToggleSync($syncId: ID!) {\n toggleSyncActive(syncId: $syncId) {\n ok\n }\n}": typeof types.ToggleSyncDocument, + "mutation TriggerEnvSync($syncId: ID!) {\n triggerSync(syncId: $syncId) {\n sync {\n status\n }\n }\n}": typeof types.TriggerEnvSyncDocument, + "mutation UpdateProviderCreds($credentialId: ID!, $name: String!, $credentials: JSONString!) {\n updateProviderCredentials(\n credentialId: $credentialId\n name: $name\n credentials: $credentials\n ) {\n credential {\n id\n }\n }\n}": typeof types.UpdateProviderCredsDocument, + "mutation UpdateSyncAuth($syncId: ID!, $credentialId: ID!) {\n updateSyncAuthentication(syncId: $syncId, credentialId: $credentialId) {\n sync {\n id\n status\n }\n }\n}": typeof types.UpdateSyncAuthDocument, + "mutation CreateNewVaultSync($envId: ID!, $path: String!, $engine: String!, $vaultPath: String!, $credentialId: ID!) {\n createVaultSync(\n envId: $envId\n path: $path\n engine: $engine\n vaultPath: $vaultPath\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewVaultSyncDocument, + "mutation CreateNewVercelSync($envId: ID!, $path: String!, $credentialId: ID!, $projectId: String!, $projectName: String!, $teamId: String!, $teamName: String!, $environment: String!, $secretType: String!) {\n createVercelSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n projectId: $projectId\n projectName: $projectName\n teamId: $teamId\n teamName: $teamName\n environment: $environment\n secretType: $secretType\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": typeof types.CreateNewVercelSyncDocument, + "mutation CreateNewUserToken($orgId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createUserToken(\n orgId: $orgId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n ok\n }\n}": typeof types.CreateNewUserTokenDocument, + "mutation RevokeUserToken($tokenId: ID!) {\n deleteUserToken(tokenId: $tokenId) {\n ok\n }\n}": typeof types.RevokeUserTokenDocument, + "query GetIP {\n clientIp\n}": typeof types.GetIpDocument, + "query GetNetworkPolicies($organisationId: ID!) {\n networkAccessPolicies(organisationId: $organisationId) {\n id\n name\n allowedIps\n isGlobal\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n clientIp\n}": typeof types.GetNetworkPoliciesDocument, + "query GetAppAccounts($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n appServiceAccounts(appId: $appId) {\n id\n identityKey\n name\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n tokens {\n id\n name\n }\n }\n}": typeof types.GetAppAccountsDocument, + "query GetAppMembers($appId: ID!) {\n appUsers(appId: $appId) {\n id\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n }\n}": typeof types.GetAppMembersDocument, + "query GetAppServiceAccounts($appId: ID!) {\n appServiceAccounts(appId: $appId) {\n id\n identityKey\n name\n createdAt\n role {\n id\n name\n description\n permissions\n color\n }\n tokens {\n id\n name\n }\n }\n}": typeof types.GetAppServiceAccountsDocument, + "query GetCheckoutDetails($stripeSessionId: String!) {\n stripeCheckoutDetails(stripeSessionId: $stripeSessionId) {\n paymentStatus\n customerEmail\n billingStartDate\n billingEndDate\n subscriptionId\n planName\n }\n}": typeof types.GetCheckoutDetailsDocument, + "query GetCustomerPortalLink($organisationId: ID!) {\n stripeCustomerPortalUrl(organisationId: $organisationId)\n}": typeof types.GetCustomerPortalLinkDocument, + "query GetSubscriptionDetails($organisationId: ID!) {\n stripeSubscriptionDetails(organisationId: $organisationId) {\n subscriptionId\n planName\n planType\n billingPeriod\n status\n nextPaymentAmount\n currentPeriodStart\n currentPeriodEnd\n renewalDate\n cancelAt\n cancelAtPeriodEnd\n paymentMethods {\n id\n brand\n last4\n expMonth\n expYear\n isDefault\n }\n }\n}": typeof types.GetSubscriptionDetailsDocument, + "query GetStripeSubscriptionEstimate($organisationId: ID!, $planType: PlanTypeEnum!, $billingPeriod: BillingPeriodEnum!, $previewV2: Boolean) {\n estimateStripeSubscription(\n organisationId: $organisationId\n planType: $planType\n billingPeriod: $billingPeriod\n previewV2: $previewV2\n ) {\n estimatedTotal\n seatCount\n unitPrice\n currency\n priceId\n }\n}": typeof types.GetStripeSubscriptionEstimateDocument, + "query GetAppActivityChart($appId: ID!, $period: TimeRange) {\n appActivityChart(appId: $appId, period: $period) {\n index\n date\n data\n }\n}": typeof types.GetAppActivityChartDocument, + "query GetAppDetail($organisationId: ID!, $appId: ID!) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n description\n identityKey\n createdAt\n appToken\n appSeed\n appVersion\n sseEnabled\n }\n}": typeof types.GetAppDetailDocument, + "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n kmsLogs(appId: $appId, start: $start, end: $end) {\n logs {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n count\n }\n}": typeof types.GetAppKmsLogsDocument, + "query GetApps($organisationId: ID!, $appId: ID) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n description\n identityKey\n createdAt\n updatedAt\n sseEnabled\n members {\n id\n email\n fullName\n avatarUrl\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n envType\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": typeof types.GetAppsDocument, + "query GetDashboard($organisationId: ID!) {\n apps(organisationId: $organisationId) {\n id\n name\n sseEnabled\n }\n userTokens(organisationId: $organisationId) {\n id\n }\n organisationInvites(orgId: $organisationId) {\n id\n }\n organisationMembers(organisationId: $organisationId, role: null) {\n id\n }\n savedCredentials(orgId: $organisationId) {\n id\n }\n syncs(orgId: $organisationId) {\n id\n }\n}": typeof types.GetDashboardDocument, + "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}": typeof types.GetOrganisationsDocument, + "query GetAwsStsEndpoints {\n awsStsEndpoints\n}": typeof types.GetAwsStsEndpointsDocument, + "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\n }\n}": typeof types.GetIdentityProvidersDocument, + "query GetOrganisationIdentities($organisationId: ID!) {\n identities(organisationId: $organisationId) {\n id\n provider\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": typeof types.GetOrganisationIdentitiesDocument, + "query CheckOrganisationNameAvailability($name: String!) {\n organisationNameAvailable(name: $name)\n}": typeof types.CheckOrganisationNameAvailabilityDocument, + "query GetGlobalAccessUsers($organisationId: ID!) {\n organisationGlobalAccessUsers(organisationId: $organisationId) {\n id\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": typeof types.GetGlobalAccessUsersDocument, + "query GetInvites($orgId: ID!) {\n organisationInvites(orgId: $orgId) {\n id\n createdAt\n expiresAt\n invitedBy {\n email\n fullName\n self\n }\n inviteeEmail\n role {\n id\n name\n description\n color\n }\n }\n}": typeof types.GetInvitesDocument, + "query GetLicenseData {\n license {\n id\n customerName\n organisationName\n expiresAt\n plan\n seats\n isActivated\n organisationOwner {\n fullName\n email\n }\n }\n}": typeof types.GetLicenseDataDocument, + "query GetOrgLicense($organisationId: ID!) {\n organisationLicense(organisationId: $organisationId) {\n id\n customerName\n issuedAt\n expiresAt\n activatedAt\n plan\n seats\n tokens\n }\n}": typeof types.GetOrgLicenseDocument, + "query GetOrganisationMembers($organisationId: ID!, $role: [String]) {\n organisationMembers(organisationId: $organisationId, role: $role) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n }\n}": typeof types.GetOrganisationMembersDocument, + "query GetOrganisationPlan($organisationId: ID!) {\n organisationPlan(organisationId: $organisationId) {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n seatLimit\n appCount\n }\n}": typeof types.GetOrganisationPlanDocument, + "query GetRoles($orgId: ID!) {\n roles(orgId: $orgId) {\n id\n name\n description\n color\n permissions\n isDefault\n }\n}": typeof types.GetRolesDocument, + "query VerifyInvite($inviteId: ID!) {\n validateInvite(inviteId: $inviteId) {\n id\n organisation {\n id\n name\n }\n inviteeEmail\n invitedBy {\n fullName\n email\n }\n apps {\n id\n name\n }\n }\n}": typeof types.VerifyInviteDocument, + "query GetDynamicSecrets($orgId: ID!, $appId: ID, $envId: ID, $path: String) {\n dynamicSecrets(orgId: $orgId, appId: $appId, envId: $envId, path: $path) {\n id\n name\n environment {\n id\n name\n index\n app {\n id\n name\n }\n }\n path\n description\n provider\n config {\n ... on AWSConfigType {\n usernameTemplate\n iamPath\n }\n }\n keyMap {\n id\n keyName\n masked\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": typeof types.GetDynamicSecretsDocument, + "query GetDynamicSecretProviders {\n dynamicSecretProviders {\n id\n name\n credentials\n configMap\n }\n}": typeof types.GetDynamicSecretProvidersDocument, + "query GetDynamicSecretLeases($secretId: ID!, $orgId: ID!) {\n dynamicSecrets(secretId: $secretId, orgId: $orgId) {\n id\n leases {\n id\n name\n ttl\n createdAt\n expiresAt\n revokedAt\n status\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n events {\n id\n eventType\n createdAt\n metadata\n ipAddress\n userAgent\n organisationMember {\n id\n fullName\n email\n avatarUrl\n self\n }\n serviceAccount {\n id\n name\n }\n }\n }\n }\n}": typeof types.GetDynamicSecretLeasesDocument, + "query GetAppEnvironments($appId: ID!, $memberId: ID, $memberType: MemberType) {\n appEnvironments(\n appId: $appId\n environmentId: null\n memberId: $memberId\n memberType: $memberType\n ) {\n id\n name\n envType\n identityKey\n wrappedSeed\n wrappedSalt\n createdAt\n app {\n name\n id\n }\n secretCount\n folderCount\n index\n members {\n email\n fullName\n avatarUrl\n }\n }\n sseEnabled(appId: $appId)\n serverPublicKey\n}": typeof types.GetAppEnvironmentsDocument, + "query GetAppSecrets($appId: ID!, $memberId: ID, $memberType: MemberType, $path: String) {\n appEnvironments(\n appId: $appId\n environmentId: null\n memberId: $memberId\n memberType: $memberType\n ) {\n id\n name\n envType\n identityKey\n wrappedSeed\n wrappedSalt\n createdAt\n app {\n name\n id\n }\n secretCount\n folderCount\n index\n members {\n email\n fullName\n avatarUrl\n }\n folders {\n id\n name\n path\n }\n secrets(path: $path) {\n id\n key\n value\n comment\n path\n type\n tags {\n id\n name\n color\n }\n }\n dynamicSecrets(path: $path) {\n id\n name\n path\n description\n provider\n keyMap {\n id\n keyName\n }\n }\n }\n sseEnabled(appId: $appId)\n serverPublicKey\n}": typeof types.GetAppSecretsDocument, + "query GetAppSecretsLogs($appId: ID!, $start: BigInt, $end: BigInt, $eventTypes: [String], $memberId: ID, $memberType: MemberType, $environmentId: ID) {\n secretLogs(\n appId: $appId\n start: $start\n end: $end\n eventTypes: $eventTypes\n memberId: $memberId\n memberType: $memberType\n environmentId: $environmentId\n ) {\n logs {\n id\n path\n key\n value\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n serviceToken {\n id\n name\n }\n serviceAccount {\n id\n name\n deletedAt\n }\n serviceAccountToken {\n id\n name\n deletedAt\n }\n eventType\n environment {\n id\n envType\n name\n }\n secret {\n id\n path\n }\n }\n count\n }\n environmentKeys(appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n environment {\n id\n }\n }\n}": typeof types.GetAppSecretsLogsDocument, + "query GetEnvironmentKey($envId: ID!, $appId: ID!) {\n environmentKeys(environmentId: $envId, appId: $appId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": typeof types.GetEnvironmentKeyDocument, + "query GetEnvironmentTokens($envId: ID!) {\n environmentTokens(environmentId: $envId) {\n id\n name\n wrappedKeyShare\n createdAt\n }\n}": typeof types.GetEnvironmentTokensDocument, + "query GetFolders($envId: ID!, $path: String) {\n folders(envId: $envId, path: $path) {\n id\n name\n path\n createdAt\n folderCount\n secretCount\n }\n}": typeof types.GetFoldersDocument, + "query GetOrgSecretKeys($organisationId: ID!) {\n apps(organisationId: $organisationId) {\n id\n name\n environments {\n id\n name\n wrappedSeed\n wrappedSalt\n secrets {\n id\n key\n path\n }\n }\n }\n}": typeof types.GetOrgSecretKeysDocument, + "query GetSecretHistory($appId: ID!, $envId: ID!, $id: ID!) {\n secrets(envId: $envId, id: $id) {\n id\n history {\n id\n key\n value\n type\n path\n tags {\n id\n name\n color\n }\n version\n comment\n timestamp\n ipAddress\n userAgent\n user {\n email\n username\n fullName\n avatarUrl\n }\n serviceToken {\n id\n name\n }\n serviceAccount {\n id\n name\n deletedAt\n }\n eventType\n }\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": typeof types.GetSecretHistoryDocument, + "query GetEnvSecretsKV($envId: ID!) {\n folders(envId: $envId, path: \"/\") {\n id\n name\n }\n secrets(envId: $envId, path: \"/\") {\n id\n key\n value\n comment\n path\n }\n environmentKeys(environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n}": typeof types.GetEnvSecretsKvDocument, + "query GetSecretTags($orgId: ID!) {\n secretTags(orgId: $orgId) {\n id\n name\n color\n }\n}": typeof types.GetSecretTagsDocument, + "query GetSecrets($appId: ID!, $envId: ID!, $path: String) {\n secrets(envId: $envId, path: $path) {\n id\n key\n value\n path\n type\n tags {\n id\n name\n color\n }\n comment\n createdAt\n updatedAt\n override {\n value\n isActive\n }\n environment {\n id\n app {\n id\n }\n }\n }\n folders(envId: $envId, path: $path) {\n id\n name\n path\n createdAt\n folderCount\n secretCount\n }\n appEnvironments(appId: $appId, environmentId: $envId) {\n id\n name\n envType\n identityKey\n app {\n id\n name\n sseEnabled\n }\n }\n environmentKeys(appId: $appId, environmentId: $envId) {\n id\n identityKey\n wrappedSeed\n wrappedSalt\n }\n envSyncs(envId: $envId) {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n options\n isActive\n status\n lastSync\n createdAt\n }\n dynamicSecrets(envId: $envId, path: $path) {\n id\n name\n path\n description\n provider\n keyMap {\n id\n keyName\n masked\n }\n config {\n ... on AWSConfigType {\n usernameTemplate\n groups\n iamPath\n permissionBoundaryArn\n policyArns\n policyDocument\n }\n }\n defaultTtlSeconds\n maxTtlSeconds\n authentication {\n id\n name\n }\n createdAt\n }\n}": typeof types.GetSecretsDocument, + "query GetServiceTokens($appId: ID!) {\n serviceTokens(appId: $appId) {\n id\n name\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n expiresAt\n keys {\n id\n identityKey\n }\n }\n}": typeof types.GetServiceTokensDocument, + "query GetServiceAccountDetail($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n serverSideKeyManagementEnabled\n role {\n id\n name\n description\n color\n permissions\n }\n createdAt\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n appMemberships {\n id\n name\n environments {\n id\n name\n }\n sseEnabled\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n identities {\n id\n provider\n name\n description\n }\n }\n}": typeof types.GetServiceAccountDetailDocument, + "query GetServiceAccountHandlers($orgId: ID!) {\n serviceAccountHandlers(orgId: $orgId) {\n id\n email\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": typeof types.GetServiceAccountHandlersDocument, + "query GetServiceAccountTokens($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n tokens {\n id\n name\n createdAt\n expiresAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n createdByServiceAccount {\n id\n name\n identityKey\n }\n lastUsed\n }\n }\n}": typeof types.GetServiceAccountTokensDocument, + "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": typeof types.GetServiceAccountsDocument, + "query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}": typeof types.GetOrgSsoProvidersDocument, + "query GetOrganisationSyncs($orgId: ID!) {\n syncs(orgId: $orgId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n apps(organisationId: $orgId, appId: null) {\n id\n name\n identityKey\n createdAt\n sseEnabled\n members {\n id\n fullName\n avatarUrl\n email\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": typeof types.GetOrganisationSyncsDocument, + "query GetAwsSecrets($credentialId: ID!) {\n awsSecrets(credentialId: $credentialId) {\n name\n arn\n }\n}": typeof types.GetAwsSecretsDocument, + "query ValidateAWSAssumeRoleAuth {\n validateAwsAssumeRoleAuth {\n valid\n message\n method\n error\n }\n}": typeof types.ValidateAwsAssumeRoleAuthDocument, + "query ValidateAWSAssumeRoleCredentials($roleArn: String!, $region: String, $externalId: String) {\n validateAwsAssumeRoleCredentials(\n roleArn: $roleArn\n region: $region\n externalId: $externalId\n ) {\n valid\n message\n error\n assumedRoleArn\n }\n}": typeof types.ValidateAwsAssumeRoleCredentialsDocument, + "query GetAzureKeyVaultSecrets($credentialId: ID!, $vaultUri: String!) {\n azureKvSecrets(credentialId: $credentialId, vaultUri: $vaultUri) {\n name\n updatedOn\n contentType\n }\n}": typeof types.GetAzureKeyVaultSecretsDocument, + "query GetCfPages($credentialId: ID!) {\n cloudflarePagesProjects(credentialId: $credentialId) {\n name\n deploymentId\n environments\n }\n}": typeof types.GetCfPagesDocument, + "query GetCfWorkers($credentialId: ID!) {\n cloudflareWorkers(credentialId: $credentialId) {\n name\n scriptId\n }\n}": typeof types.GetCfWorkersDocument, + "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": typeof types.GetAppSyncStatusDocument, + "query GetProviderList {\n providers {\n id\n name\n expectedCredentials\n optionalCredentials\n authScheme\n }\n serverPublicKey\n}": typeof types.GetProviderListDocument, + "query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": typeof types.GetSavedCredentialsDocument, + "query GetServerKey {\n serverPublicKey\n}": typeof types.GetServerKeyDocument, + "query GetServiceList {\n services {\n id\n name\n provider {\n id\n }\n }\n}": typeof types.GetServiceListDocument, + "query GetGithubEnvironments($credentialId: ID!, $owner: String!, $repoName: String!) {\n githubEnvironments(\n credentialId: $credentialId\n owner: $owner\n repoName: $repoName\n )\n}": typeof types.GetGithubEnvironmentsDocument, + "query GetGithubOrgs($credentialId: ID!) {\n githubOrgs(credentialId: $credentialId) {\n name\n role\n }\n}": typeof types.GetGithubOrgsDocument, + "query GetGithubRepos($credentialId: ID!) {\n githubRepos(credentialId: $credentialId) {\n name\n owner\n type\n }\n}": typeof types.GetGithubReposDocument, + "query GetGitLabResources($credentialId: ID!) {\n gitlabProjects(credentialId: $credentialId) {\n id\n name\n namespace {\n name\n fullPath\n }\n pathWithNamespace\n webUrl\n }\n gitlabGroups(credentialId: $credentialId) {\n id\n fullName\n fullPath\n webUrl\n }\n}": typeof types.GetGitLabResourcesDocument, + "query TestNomadAuth($credentialId: ID!) {\n testNomadCreds(credentialId: $credentialId)\n}": typeof types.TestNomadAuthDocument, + "query GetRailwayProjects($credentialId: ID!) {\n railwayProjects(credentialId: $credentialId) {\n id\n name\n environments {\n id\n name\n }\n services {\n id\n name\n }\n }\n}": typeof types.GetRailwayProjectsDocument, + "query GetRenderResources($credentialId: ID!) {\n renderServices(credentialId: $credentialId) {\n id\n name\n type\n }\n renderEnvgroups(credentialId: $credentialId) {\n id\n name\n }\n}": typeof types.GetRenderResourcesDocument, + "query TestVaultAuth($credentialId: ID!) {\n testVaultCreds(credentialId: $credentialId)\n}": typeof types.TestVaultAuthDocument, + "query GetVercelProjects($credentialId: ID!) {\n vercelProjects(credentialId: $credentialId) {\n id\n teamName\n projects {\n id\n name\n environments {\n id\n name\n slug\n type\n }\n }\n }\n}": typeof types.GetVercelProjectsDocument, + "query GetOrganisationMemberDetail($organisationId: ID!, $id: ID) {\n organisationMembers(organisationId: $organisationId, memberId: $id) {\n id\n role {\n id\n name\n description\n permissions\n color\n }\n identityKey\n email\n fullName\n avatarUrl\n createdAt\n lastLogin\n self\n appMemberships {\n id\n name\n sseEnabled\n environments {\n id\n name\n }\n }\n tokens {\n id\n name\n createdAt\n expiresAt\n }\n networkPolicies {\n id\n name\n allowedIps\n isGlobal\n }\n }\n}": typeof types.GetOrganisationMemberDetailDocument, + "query GetUserTokens($organisationId: ID!) {\n userTokens(organisationId: $organisationId) {\n id\n name\n wrappedKeyShare\n createdAt\n expiresAt\n }\n}": typeof types.GetUserTokensDocument, +}; +const documents: Documents = { "mutation CreateAccessPolicy($name: String!, $allowedIps: String!, $isGlobal: Boolean!, $organisationId: ID!) {\n createNetworkAccessPolicy(\n name: $name\n allowedIps: $allowedIps\n isGlobal: $isGlobal\n organisationId: $organisationId\n ) {\n networkAccessPolicy {\n id\n }\n }\n}": types.CreateAccessPolicyDocument, "mutation CreateRole($name: String!, $description: String!, $color: String!, $permissions: JSONString!, $organisationId: ID!) {\n createCustomRole(\n name: $name\n description: $description\n color: $color\n permissions: $permissions\n organisationId: $organisationId\n ) {\n role {\n id\n }\n }\n}": types.CreateRoleDocument, "mutation DeleteAccessPolicy($id: ID!) {\n deleteNetworkAccessPolicy(id: $id) {\n ok\n }\n}": types.DeleteAccessPolicyDocument, @@ -81,6 +249,11 @@ const documents = { "mutation EnableSAServerKeyManagement($serviceAccountId: ID!, $serverWrappedKeyring: String!, $serverWrappedRecovery: String!) {\n enableServiceAccountServerSideKeyManagement(\n serviceAccountId: $serviceAccountId\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n name\n serverSideKeyManagementEnabled\n }\n }\n}": types.EnableSaServerKeyManagementDocument, "mutation UpdateServiceAccountHandlerKeys($orgId: ID!, $handlers: [ServiceAccountHandlerInput]) {\n updateServiceAccountHandlers(organisationId: $orgId, handlers: $handlers) {\n ok\n }\n}": types.UpdateServiceAccountHandlerKeysDocument, "mutation UpdateServiceAccountOp($serviceAccountId: ID!, $name: String!, $roleId: ID!, $identityIds: [ID!]) {\n updateServiceAccount(\n serviceAccountId: $serviceAccountId\n name: $name\n roleId: $roleId\n identityIds: $identityIds\n ) {\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n permissions\n }\n identities {\n id\n name\n }\n }\n }\n}": types.UpdateServiceAccountOpDocument, + "mutation CreateOrgSSOProvider($orgId: ID!, $providerType: String!, $name: String!, $config: JSONString!) {\n createOrganisationSsoProvider(\n orgId: $orgId\n providerType: $providerType\n name: $name\n config: $config\n ) {\n providerId\n }\n}": types.CreateOrgSsoProviderDocument, + "mutation DeleteOrgSSOProvider($providerId: ID!) {\n deleteOrganisationSsoProvider(providerId: $providerId) {\n ok\n }\n}": types.DeleteOrgSsoProviderDocument, + "mutation TestOrgSSOProvider($providerId: ID!) {\n testOrganisationSsoProvider(providerId: $providerId) {\n success\n error\n }\n}": types.TestOrgSsoProviderDocument, + "mutation UpdateOrgSSOProvider($providerId: ID!, $name: String, $config: JSONString, $enabled: Boolean) {\n updateOrganisationSsoProvider(\n providerId: $providerId\n name: $name\n config: $config\n enabled: $enabled\n ) {\n ok\n }\n}": types.UpdateOrgSsoProviderDocument, + "mutation UpdateOrgSecurity($orgId: ID!, $requireSso: Boolean!) {\n updateOrganisationSecurity(orgId: $orgId, requireSso: $requireSso) {\n ok\n sessionInvalidated\n }\n}": types.UpdateOrgSecurityDocument, "mutation CreateNewAWSSecretsSync($envId: ID!, $path: String!, $credentialId: ID!, $secretName: String!, $kmsId: String) {\n createAwsSecretSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n secretName: $secretName\n kmsId: $kmsId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewAwsSecretsSyncDocument, "mutation CreateNewAzureKeyVaultSync($envId: ID!, $path: String!, $credentialId: ID!, $vaultUri: String!, $syncMode: String!, $secretName: String) {\n createAzureKeyVaultSync(\n envId: $envId\n path: $path\n credentialId: $credentialId\n vaultUri: $vaultUri\n syncMode: $syncMode\n secretName: $secretName\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewAzureKeyVaultSyncDocument, "mutation CreateNewCfPagesSync($envId: ID!, $path: String!, $projectName: String!, $deploymentId: ID!, $projectEnv: String!, $credentialId: ID!) {\n createCloudflarePagesSync(\n envId: $envId\n path: $path\n projectName: $projectName\n deploymentId: $deploymentId\n projectEnv: $projectEnv\n credentialId: $credentialId\n ) {\n sync {\n id\n environment {\n id\n name\n envType\n }\n serviceInfo {\n id\n name\n }\n isActive\n lastSync\n createdAt\n }\n }\n}": types.CreateNewCfPagesSyncDocument, @@ -117,7 +290,7 @@ const documents = { "query GetAppKmsLogs($appId: ID!, $start: BigInt, $end: BigInt) {\n kmsLogs(appId: $appId, start: $start, end: $end) {\n logs {\n id\n timestamp\n phaseNode\n eventType\n ipAddress\n country\n city\n phSize\n }\n count\n }\n}": types.GetAppKmsLogsDocument, "query GetApps($organisationId: ID!, $appId: ID) {\n apps(organisationId: $organisationId, appId: $appId) {\n id\n name\n description\n identityKey\n createdAt\n updatedAt\n sseEnabled\n members {\n id\n email\n fullName\n avatarUrl\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n envType\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": types.GetAppsDocument, "query GetDashboard($organisationId: ID!) {\n apps(organisationId: $organisationId) {\n id\n name\n sseEnabled\n }\n userTokens(organisationId: $organisationId) {\n id\n }\n organisationInvites(orgId: $organisationId) {\n id\n }\n organisationMembers(organisationId: $organisationId, role: null) {\n id\n }\n savedCredentials(orgId: $organisationId) {\n id\n }\n syncs(orgId: $organisationId) {\n id\n }\n}": types.GetDashboardDocument, - "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n }\n}": types.GetOrganisationsDocument, + "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}": types.GetOrganisationsDocument, "query GetAwsStsEndpoints {\n awsStsEndpoints\n}": types.GetAwsStsEndpointsDocument, "query GetIdentityProviders {\n identityProviders {\n id\n name\n description\n iconId\n }\n}": types.GetIdentityProvidersDocument, "query GetOrganisationIdentities($organisationId: ID!) {\n identities(organisationId: $organisationId) {\n id\n provider\n name\n description\n config {\n ... on AwsIamConfigType {\n trustedPrincipals\n signatureTtlSeconds\n stsEndpoint\n }\n ... on AzureEntraConfigType {\n tenantId\n resource\n allowedServicePrincipalIds\n }\n }\n tokenNamePattern\n defaultTtlSeconds\n maxTtlSeconds\n createdAt\n }\n}": types.GetOrganisationIdentitiesDocument, @@ -149,6 +322,7 @@ const documents = { "query GetServiceAccountHandlers($orgId: ID!) {\n serviceAccountHandlers(orgId: $orgId) {\n id\n email\n role {\n name\n permissions\n }\n identityKey\n self\n }\n}": types.GetServiceAccountHandlersDocument, "query GetServiceAccountTokens($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n tokens {\n id\n name\n createdAt\n expiresAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n createdByServiceAccount {\n id\n name\n identityKey\n }\n lastUsed\n }\n }\n}": types.GetServiceAccountTokensDocument, "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}": types.GetServiceAccountsDocument, + "query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}": types.GetOrgSsoProvidersDocument, "query GetOrganisationSyncs($orgId: ID!) {\n syncs(orgId: $orgId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n apps(organisationId: $orgId, appId: null) {\n id\n name\n identityKey\n createdAt\n sseEnabled\n members {\n id\n fullName\n avatarUrl\n email\n }\n serviceAccounts {\n id\n name\n }\n environments {\n id\n name\n syncs {\n id\n serviceInfo {\n id\n name\n provider {\n id\n name\n }\n }\n status\n }\n }\n }\n}": types.GetOrganisationSyncsDocument, "query GetAwsSecrets($credentialId: ID!) {\n awsSecrets(credentialId: $credentialId) {\n name\n arn\n }\n}": types.GetAwsSecretsDocument, "query ValidateAWSAssumeRoleAuth {\n validateAwsAssumeRoleAuth {\n valid\n message\n method\n error\n }\n}": types.ValidateAwsAssumeRoleAuthDocument, @@ -460,6 +634,26 @@ export function graphql(source: "mutation UpdateServiceAccountHandlerKeys($orgId * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation UpdateServiceAccountOp($serviceAccountId: ID!, $name: String!, $roleId: ID!, $identityIds: [ID!]) {\n updateServiceAccount(\n serviceAccountId: $serviceAccountId\n name: $name\n roleId: $roleId\n identityIds: $identityIds\n ) {\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n permissions\n }\n identities {\n id\n name\n }\n }\n }\n}"): (typeof documents)["mutation UpdateServiceAccountOp($serviceAccountId: ID!, $name: String!, $roleId: ID!, $identityIds: [ID!]) {\n updateServiceAccount(\n serviceAccountId: $serviceAccountId\n name: $name\n roleId: $roleId\n identityIds: $identityIds\n ) {\n serviceAccount {\n id\n name\n role {\n id\n name\n description\n permissions\n }\n identities {\n id\n name\n }\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation CreateOrgSSOProvider($orgId: ID!, $providerType: String!, $name: String!, $config: JSONString!) {\n createOrganisationSsoProvider(\n orgId: $orgId\n providerType: $providerType\n name: $name\n config: $config\n ) {\n providerId\n }\n}"): (typeof documents)["mutation CreateOrgSSOProvider($orgId: ID!, $providerType: String!, $name: String!, $config: JSONString!) {\n createOrganisationSsoProvider(\n orgId: $orgId\n providerType: $providerType\n name: $name\n config: $config\n ) {\n providerId\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation DeleteOrgSSOProvider($providerId: ID!) {\n deleteOrganisationSsoProvider(providerId: $providerId) {\n ok\n }\n}"): (typeof documents)["mutation DeleteOrgSSOProvider($providerId: ID!) {\n deleteOrganisationSsoProvider(providerId: $providerId) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation TestOrgSSOProvider($providerId: ID!) {\n testOrganisationSsoProvider(providerId: $providerId) {\n success\n error\n }\n}"): (typeof documents)["mutation TestOrgSSOProvider($providerId: ID!) {\n testOrganisationSsoProvider(providerId: $providerId) {\n success\n error\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateOrgSSOProvider($providerId: ID!, $name: String, $config: JSONString, $enabled: Boolean) {\n updateOrganisationSsoProvider(\n providerId: $providerId\n name: $name\n config: $config\n enabled: $enabled\n ) {\n ok\n }\n}"): (typeof documents)["mutation UpdateOrgSSOProvider($providerId: ID!, $name: String, $config: JSONString, $enabled: Boolean) {\n updateOrganisationSsoProvider(\n providerId: $providerId\n name: $name\n config: $config\n enabled: $enabled\n ) {\n ok\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation UpdateOrgSecurity($orgId: ID!, $requireSso: Boolean!) {\n updateOrganisationSecurity(orgId: $orgId, requireSso: $requireSso) {\n ok\n sessionInvalidated\n }\n}"): (typeof documents)["mutation UpdateOrgSecurity($orgId: ID!, $requireSso: Boolean!) {\n updateOrganisationSecurity(orgId: $orgId, requireSso: $requireSso) {\n ok\n sessionInvalidated\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -607,7 +801,7 @@ export function graphql(source: "query GetDashboard($organisationId: ID!) {\n a /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n }\n}"): (typeof documents)["query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n }\n}"]; +export function graphql(source: "query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}"): (typeof documents)["query GetOrganisations {\n organisations {\n id\n name\n identityKey\n createdAt\n plan\n planDetail {\n name\n maxUsers\n maxApps\n maxEnvsPerApp\n seatsUsed {\n users\n serviceAccounts\n total\n }\n appCount\n }\n role {\n name\n description\n color\n permissions\n }\n memberId\n keyring\n recovery\n pricingVersion\n requireSso\n ssoProviders {\n name\n providerType\n enabled\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -732,6 +926,10 @@ export function graphql(source: "query GetServiceAccountTokens($orgId: ID!, $id: * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"): (typeof documents)["query GetServiceAccounts($orgId: ID!, $id: ID) {\n serviceAccounts(orgId: $orgId, serviceAccountId: $id) {\n id\n name\n identityKey\n role {\n id\n name\n description\n color\n }\n handlers {\n id\n wrappedKeyring\n wrappedRecovery\n user {\n self\n }\n }\n createdAt\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}"): (typeof documents)["query GetOrgSSOProviders {\n organisations {\n id\n name\n requireSso\n ssoProviders {\n id\n providerType\n name\n publicConfig\n enabled\n createdAt\n createdBy {\n fullName\n avatarUrl\n self\n }\n updatedAt\n updatedBy {\n fullName\n avatarUrl\n self\n }\n }\n }\n serverPublicKey\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index c3f586839..53ed031cc 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -1,7 +1,7 @@ /* eslint-disable */ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; export type Maybe = T | null; -export type InputMaybe = Maybe; +export type InputMaybe = T | null | undefined; export type Exact = { [K in keyof T]: T[K] }; export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; @@ -209,6 +209,14 @@ export enum ApiOrganisationPlanChoices { Pr = 'PR' } +/** An enumeration. */ +export enum ApiOrganisationSsoProviderProviderTypeChoices { + /** Microsoft Entra ID */ + EntraId = 'ENTRA_ID', + /** Okta */ + Okta = 'OKTA' +} + /** An enumeration. */ export enum ApiSecretEventEventTypeChoices { /** Create */ @@ -446,6 +454,11 @@ export type CreateOrganisationMutation = { organisation?: Maybe; }; +export type CreateOrganisationSsoProviderMutation = { + __typename?: 'CreateOrganisationSSOProviderMutation'; + providerId?: Maybe; +}; + export type CreatePersonalSecretMutation = { __typename?: 'CreatePersonalSecretMutation'; override?: Maybe; @@ -562,6 +575,11 @@ export type DeleteOrganisationMemberMutation = { ok?: Maybe; }; +export type DeleteOrganisationSsoProviderMutation = { + __typename?: 'DeleteOrganisationSSOProviderMutation'; + ok?: Maybe; +}; + export type DeletePaymentMethodMutation = { __typename?: 'DeletePaymentMethodMutation'; ok?: Maybe; @@ -981,6 +999,7 @@ export type Mutation = { createNomadSync?: Maybe; createOrganisation?: Maybe; createOrganisationMember?: Maybe; + createOrganisationSsoProvider?: Maybe; createOverride?: Maybe; createProviderCredentials?: Maybe; createRailwaySync?: Maybe; @@ -1006,6 +1025,7 @@ export type Mutation = { deleteInvitation?: Maybe; deleteNetworkAccessPolicy?: Maybe; deleteOrganisationMember?: Maybe; + deleteOrganisationSsoProvider?: Maybe; deletePaymentMethod?: Maybe; deleteProviderCredentials?: Maybe; deleteSecret?: Maybe; @@ -1031,6 +1051,7 @@ export type Mutation = { revokeDynamicSecretLease?: Maybe; rotateAppKeys?: Maybe; setDefaultPaymentMethod?: Maybe; + testOrganisationSsoProvider?: Maybe; toggleSyncActive?: Maybe; /** * Transfer organisation ownership from the current owner to another member. @@ -1048,6 +1069,8 @@ export type Mutation = { updateMemberWrappedSecrets?: Maybe; updateNetworkAccessPolicy?: Maybe; updateOrganisationMemberRole?: Maybe; + updateOrganisationSecurity?: Maybe; + updateOrganisationSsoProvider?: Maybe; updateProviderCredentials?: Maybe; updateServiceAccount?: Maybe; updateServiceAccountHandlers?: Maybe; @@ -1277,6 +1300,14 @@ export type MutationCreateOrganisationMemberArgs = { }; +export type MutationCreateOrganisationSsoProviderArgs = { + config: Scalars['JSONString']['input']; + name: Scalars['String']['input']; + orgId: Scalars['ID']['input']; + providerType: Scalars['String']['input']; +}; + + export type MutationCreateOverrideArgs = { overrideData?: InputMaybe; }; @@ -1456,6 +1487,11 @@ export type MutationDeleteOrganisationMemberArgs = { }; +export type MutationDeleteOrganisationSsoProviderArgs = { + providerId: Scalars['ID']['input']; +}; + + export type MutationDeletePaymentMethodArgs = { organisationId?: InputMaybe; paymentMethodId?: InputMaybe; @@ -1597,6 +1633,11 @@ export type MutationSetDefaultPaymentMethodArgs = { }; +export type MutationTestOrganisationSsoProviderArgs = { + providerId: Scalars['ID']['input']; +}; + + export type MutationToggleSyncActiveArgs = { syncId?: InputMaybe; }; @@ -1697,6 +1738,20 @@ export type MutationUpdateOrganisationMemberRoleArgs = { }; +export type MutationUpdateOrganisationSecurityArgs = { + orgId: Scalars['ID']['input']; + requireSso: Scalars['Boolean']['input']; +}; + + +export type MutationUpdateOrganisationSsoProviderArgs = { + config?: InputMaybe; + enabled?: InputMaybe; + name?: InputMaybe; + providerId: Scalars['ID']['input']; +}; + + export type MutationUpdateProviderCredentialsArgs = { credentialId?: InputMaybe; credentials?: InputMaybe; @@ -1798,6 +1853,19 @@ export type OrganisationPlanType = { seatsUsed?: Maybe; }; +export type OrganisationSsoProviderType = { + __typename?: 'OrganisationSSOProviderType'; + createdAt: Scalars['DateTime']['output']; + createdBy?: Maybe; + enabled: Scalars['Boolean']['output']; + id: Scalars['String']['output']; + name: Scalars['String']['output']; + providerType: ApiOrganisationSsoProviderProviderTypeChoices; + publicConfig?: Maybe; + updatedAt: Scalars['DateTime']['output']; + updatedBy?: Maybe; +}; + export type OrganisationType = { __typename?: 'OrganisationType'; createdAt?: Maybe; @@ -1810,7 +1878,9 @@ export type OrganisationType = { planDetail?: Maybe; pricingVersion: Scalars['Int']['output']; recovery?: Maybe; + requireSso: Scalars['Boolean']['output']; role?: Maybe; + ssoProviders?: Maybe>>; }; export type PaymentMethodDetails = { @@ -2554,6 +2624,12 @@ export type StripeSubscriptionDetails = { subscriptionId?: Maybe; }; +export type TestOrganisationSsoProviderMutation = { + __typename?: 'TestOrganisationSSOProviderMutation'; + error?: Maybe; + success?: Maybe; +}; + export enum TimeRange { AllTime = 'ALL_TIME', Day = 'DAY', @@ -2627,6 +2703,17 @@ export type UpdateOrganisationMemberRole = { orgMember?: Maybe; }; +export type UpdateOrganisationSsoProviderMutation = { + __typename?: 'UpdateOrganisationSSOProviderMutation'; + ok?: Maybe; +}; + +export type UpdateOrganisationSecurityMutation = { + __typename?: 'UpdateOrganisationSecurityMutation'; + ok?: Maybe; + sessionInvalidated?: Maybe; +}; + export type UpdatePolicyInput = { allowedIps?: InputMaybe; id: Scalars['ID']['input']; @@ -3157,7 +3244,10 @@ export type CreateExtIdentityMutationVariables = Exact<{ }>; -export type CreateExtIdentityMutation = { __typename?: 'Mutation', createIdentity?: { __typename?: 'CreateIdentityMutation', identity?: { __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, config?: { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } | null } | null } | null }; +export type CreateExtIdentityMutation = { __typename?: 'Mutation', createIdentity?: { __typename?: 'CreateIdentityMutation', identity?: { __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, config?: + | { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } + | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } + | null } | null } | null }; export type DeleteExtIdentityMutationVariables = Exact<{ id: Scalars['ID']['input']; @@ -3181,7 +3271,10 @@ export type UpdateExtIdentityMutationVariables = Exact<{ }>; -export type UpdateExtIdentityMutation = { __typename?: 'Mutation', updateIdentity?: { __typename?: 'UpdateIdentityMutation', identity?: { __typename?: 'IdentityType', id: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, config?: { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } | null } | null } | null }; +export type UpdateExtIdentityMutation = { __typename?: 'Mutation', updateIdentity?: { __typename?: 'UpdateIdentityMutation', identity?: { __typename?: 'IdentityType', id: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, config?: + | { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } + | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } + | null } | null } | null }; export type AcceptOrganisationInviteMutationVariables = Exact<{ orgId: Scalars['ID']['input']; @@ -3324,6 +3417,48 @@ export type UpdateServiceAccountOpMutationVariables = Exact<{ export type UpdateServiceAccountOpMutation = { __typename?: 'Mutation', updateServiceAccount?: { __typename?: 'UpdateServiceAccountMutation', serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null } | null, identities?: Array<{ __typename?: 'IdentityType', id: string, name: string }> | null } | null } | null }; +export type CreateOrgSsoProviderMutationVariables = Exact<{ + orgId: Scalars['ID']['input']; + providerType: Scalars['String']['input']; + name: Scalars['String']['input']; + config: Scalars['JSONString']['input']; +}>; + + +export type CreateOrgSsoProviderMutation = { __typename?: 'Mutation', createOrganisationSsoProvider?: { __typename?: 'CreateOrganisationSSOProviderMutation', providerId?: string | null } | null }; + +export type DeleteOrgSsoProviderMutationVariables = Exact<{ + providerId: Scalars['ID']['input']; +}>; + + +export type DeleteOrgSsoProviderMutation = { __typename?: 'Mutation', deleteOrganisationSsoProvider?: { __typename?: 'DeleteOrganisationSSOProviderMutation', ok?: boolean | null } | null }; + +export type TestOrgSsoProviderMutationVariables = Exact<{ + providerId: Scalars['ID']['input']; +}>; + + +export type TestOrgSsoProviderMutation = { __typename?: 'Mutation', testOrganisationSsoProvider?: { __typename?: 'TestOrganisationSSOProviderMutation', success?: boolean | null, error?: string | null } | null }; + +export type UpdateOrgSsoProviderMutationVariables = Exact<{ + providerId: Scalars['ID']['input']; + name?: InputMaybe; + config?: InputMaybe; + enabled?: InputMaybe; +}>; + + +export type UpdateOrgSsoProviderMutation = { __typename?: 'Mutation', updateOrganisationSsoProvider?: { __typename?: 'UpdateOrganisationSSOProviderMutation', ok?: boolean | null } | null }; + +export type UpdateOrgSecurityMutationVariables = Exact<{ + orgId: Scalars['ID']['input']; + requireSso: Scalars['Boolean']['input']; +}>; + + +export type UpdateOrgSecurityMutation = { __typename?: 'Mutation', updateOrganisationSecurity?: { __typename?: 'UpdateOrganisationSecurityMutation', ok?: boolean | null, sessionInvalidated?: boolean | null } | null }; + export type CreateNewAwsSecretsSyncMutationVariables = Exact<{ envId: Scalars['ID']['input']; path: Scalars['String']['input']; @@ -3661,7 +3796,7 @@ export type GetDashboardQuery = { __typename?: 'Query', apps?: Array<{ __typenam export type GetOrganisationsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetOrganisationsQuery = { __typename?: 'Query', organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, identityKey: string, createdAt?: any | null, plan: ApiOrganisationPlanChoices, memberId?: string | null, keyring?: string | null, recovery?: string | null, pricingVersion: number, planDetail?: { __typename?: 'OrganisationPlanType', name?: string | null, maxUsers?: number | null, maxApps?: number | null, maxEnvsPerApp?: number | null, appCount?: number | null, seatsUsed?: { __typename?: 'SeatsUsed', users?: number | null, serviceAccounts?: number | null, total?: number | null } | null } | null, role?: { __typename?: 'RoleType', name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null } | null> | null }; +export type GetOrganisationsQuery = { __typename?: 'Query', organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, identityKey: string, createdAt?: any | null, plan: ApiOrganisationPlanChoices, memberId?: string | null, keyring?: string | null, recovery?: string | null, pricingVersion: number, requireSso: boolean, planDetail?: { __typename?: 'OrganisationPlanType', name?: string | null, maxUsers?: number | null, maxApps?: number | null, maxEnvsPerApp?: number | null, appCount?: number | null, seatsUsed?: { __typename?: 'SeatsUsed', users?: number | null, serviceAccounts?: number | null, total?: number | null } | null } | null, role?: { __typename?: 'RoleType', name?: string | null, description?: string | null, color?: string | null, permissions?: any | null } | null, ssoProviders?: Array<{ __typename?: 'OrganisationSSOProviderType', name: string, providerType: ApiOrganisationSsoProviderProviderTypeChoices, enabled: boolean } | null> | null } | null> | null }; export type GetAwsStsEndpointsQueryVariables = Exact<{ [key: string]: never; }>; @@ -3678,7 +3813,10 @@ export type GetOrganisationIdentitiesQueryVariables = Exact<{ }>; -export type GetOrganisationIdentitiesQuery = { __typename?: 'Query', identities?: Array<{ __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, createdAt?: any | null, config?: { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } | null } | null> | null }; +export type GetOrganisationIdentitiesQuery = { __typename?: 'Query', identities?: Array<{ __typename?: 'IdentityType', id: string, provider: string, name: string, description?: string | null, tokenNamePattern?: string | null, defaultTtlSeconds: number, maxTtlSeconds: number, createdAt?: any | null, config?: + | { __typename?: 'AwsIamConfigType', trustedPrincipals?: Array | null, signatureTtlSeconds?: number | null, stsEndpoint?: string | null } + | { __typename?: 'AzureEntraConfigType', tenantId?: string | null, resource?: string | null, allowedServicePrincipalIds?: Array | null } + | null } | null> | null }; export type CheckOrganisationNameAvailabilityQueryVariables = Exact<{ name: Scalars['String']['input']; @@ -3897,6 +4035,11 @@ export type GetServiceAccountsQueryVariables = Exact<{ export type GetServiceAccountsQuery = { __typename?: 'Query', serviceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, name: string, identityKey?: string | null, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, color?: string | null } | null, handlers?: Array<{ __typename?: 'ServiceAccountHandlerType', id: string, wrappedKeyring: string, wrappedRecovery: string, user: { __typename?: 'OrganisationMemberType', self?: boolean | null } } | null> | null } | null> | null }; +export type GetOrgSsoProvidersQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetOrgSsoProvidersQuery = { __typename?: 'Query', serverPublicKey?: string | null, organisations?: Array<{ __typename?: 'OrganisationType', id: string, name: string, requireSso: boolean, ssoProviders?: Array<{ __typename?: 'OrganisationSSOProviderType', id: string, providerType: ApiOrganisationSsoProviderProviderTypeChoices, name: string, publicConfig?: any | null, enabled: boolean, createdAt: any, updatedAt: any, createdBy?: { __typename?: 'OrganisationMemberType', fullName?: string | null, avatarUrl?: string | null, self?: boolean | null } | null, updatedBy?: { __typename?: 'OrganisationMemberType', fullName?: string | null, avatarUrl?: string | null, self?: boolean | null } | null } | null> | null } | null> | null }; + export type GetOrganisationSyncsQueryVariables = Exact<{ orgId: Scalars['ID']['input']; }>; @@ -4125,6 +4268,11 @@ export const EnableSaClientKeyManagementDocument = {"kind":"Document","definitio export const EnableSaServerKeyManagementDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EnableSAServerKeyManagement"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enableServiceAccountServerSideKeyManagement"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"serverSideKeyManagementEnabled"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateServiceAccountHandlerKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateServiceAccountHandlerKeys"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountHandlerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateServiceAccountHandlers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"handlers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const UpdateServiceAccountOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateServiceAccountOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityIds"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identities"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateOrgSsoProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrgSSOProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"config"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSONString"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrganisationSsoProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"config"},"value":{"kind":"Variable","name":{"kind":"Name","value":"config"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"providerId"}}]}}]}}]} as unknown as DocumentNode; +export const DeleteOrgSsoProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOrgSSOProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOrganisationSsoProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const TestOrgSsoProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TestOrgSSOProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testOrganisationSsoProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateOrgSsoProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOrgSSOProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"config"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"JSONString"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrganisationSsoProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"config"},"value":{"kind":"Variable","name":{"kind":"Name","value":"config"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateOrgSecurityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOrgSecurity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"requireSso"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrganisationSecurity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"requireSso"},"value":{"kind":"Variable","name":{"kind":"Name","value":"requireSso"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"sessionInvalidated"}}]}}]}}]} as unknown as DocumentNode; export const CreateNewAwsSecretsSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewAWSSecretsSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"kmsId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createAwsSecretSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}},{"kind":"Argument","name":{"kind":"Name","value":"secretName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretName"}}},{"kind":"Argument","name":{"kind":"Name","value":"kmsId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"kmsId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateNewAzureKeyVaultSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewAzureKeyVaultSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"syncMode"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"secretName"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createAzureKeyVaultSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}},{"kind":"Argument","name":{"kind":"Name","value":"vaultUri"},"value":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}}},{"kind":"Argument","name":{"kind":"Name","value":"syncMode"},"value":{"kind":"Variable","name":{"kind":"Name","value":"syncMode"}}},{"kind":"Argument","name":{"kind":"Name","value":"secretName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"secretName"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateNewCfPagesSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateNewCfPagesSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"deploymentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectEnv"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createCloudflarePagesSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"envId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envId"}}},{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"projectName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectName"}}},{"kind":"Argument","name":{"kind":"Name","value":"deploymentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"deploymentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"projectEnv"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectEnv"}}},{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sync"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode; @@ -4161,7 +4309,7 @@ export const GetAppDetailDocument = {"kind":"Document","definitions":[{"kind":"O export const GetAppKmsLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppKmsLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"start"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"end"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"kmsLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"start"},"value":{"kind":"Variable","name":{"kind":"Name","value":"start"}}},{"kind":"Argument","name":{"kind":"Name","value":"end"},"value":{"kind":"Variable","name":{"kind":"Name","value":"end"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"phaseNode"}},{"kind":"Field","name":{"kind":"Name","value":"eventType"}},{"kind":"Field","name":{"kind":"Name","value":"ipAddress"}},{"kind":"Field","name":{"kind":"Name","value":"country"}},{"kind":"Field","name":{"kind":"Name","value":"city"}},{"kind":"Field","name":{"kind":"Name","value":"phSize"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode; export const GetAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApps"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetDashboardDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetDashboard"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}}]}},{"kind":"Field","name":{"kind":"Name","value":"userTokens"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"organisationInvites"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"organisationMembers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"role"},"value":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const GetOrganisationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"planDetail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"maxUsers"}},{"kind":"Field","name":{"kind":"Name","value":"maxApps"}},{"kind":"Field","name":{"kind":"Name","value":"maxEnvsPerApp"}},{"kind":"Field","name":{"kind":"Name","value":"seatsUsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"appCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"memberId"}},{"kind":"Field","name":{"kind":"Name","value":"keyring"}},{"kind":"Field","name":{"kind":"Name","value":"recovery"}},{"kind":"Field","name":{"kind":"Name","value":"pricingVersion"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrganisationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"plan"}},{"kind":"Field","name":{"kind":"Name","value":"planDetail"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"maxUsers"}},{"kind":"Field","name":{"kind":"Name","value":"maxApps"}},{"kind":"Field","name":{"kind":"Name","value":"maxEnvsPerApp"}},{"kind":"Field","name":{"kind":"Name","value":"seatsUsed"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"users"}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"appCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"memberId"}},{"kind":"Field","name":{"kind":"Name","value":"keyring"}},{"kind":"Field","name":{"kind":"Name","value":"recovery"}},{"kind":"Field","name":{"kind":"Name","value":"pricingVersion"}},{"kind":"Field","name":{"kind":"Name","value":"requireSso"}},{"kind":"Field","name":{"kind":"Name","value":"ssoProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"providerType"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAwsStsEndpointsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAwsStsEndpoints"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"awsStsEndpoints"}}]}}]} as unknown as DocumentNode; export const GetIdentityProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetIdentityProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"identityProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"iconId"}}]}}]}}]} as unknown as DocumentNode; export const GetOrganisationIdentitiesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationIdentities"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"identities"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AwsIamConfigType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"trustedPrincipals"}},{"kind":"Field","name":{"kind":"Name","value":"signatureTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"stsEndpoint"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AzureEntraConfigType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tenantId"}},{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"allowedServicePrincipalIds"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tokenNamePattern"}},{"kind":"Field","name":{"kind":"Name","value":"defaultTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"maxTtlSeconds"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; @@ -4193,6 +4341,7 @@ export const GetServiceAccountDetailDocument = {"kind":"Document","definitions": export const GetServiceAccountHandlersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountHandlers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccountHandlers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"}}]}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]} as unknown as DocumentNode; export const GetServiceAccountTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccountTokens"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdByServiceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lastUsed"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServiceAccounts"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}},{"kind":"Field","name":{"kind":"Name","value":"handlers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedKeyring"}},{"kind":"Field","name":{"kind":"Name","value":"wrappedRecovery"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrgSsoProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrgSSOProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"organisations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"requireSso"}},{"kind":"Field","name":{"kind":"Name","value":"ssoProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"providerType"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicConfig"}},{"kind":"Field","name":{"kind":"Name","value":"enabled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"self"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetOrganisationSyncsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrganisationSyncs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"syncCount"}}]}},{"kind":"Field","name":{"kind":"Name","value":"apps"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"members"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serviceAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"environments"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAwsSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAwsSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"awsSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"arn"}}]}}]}}]} as unknown as DocumentNode; export const ValidateAwsAssumeRoleAuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateAWSAssumeRoleAuth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateAwsAssumeRoleAuth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"method"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index faa6af4eb..75ce7c565 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -71,11 +71,13 @@ type OrganisationType { createdAt: DateTime plan: ApiOrganisationPlanChoices! pricingVersion: Int! + requireSso: Boolean! role: RoleType memberId: ID keyring: String recovery: String planDetail: OrganisationPlanType + ssoProviders: [OrganisationSSOProviderType] } """ @@ -130,23 +132,25 @@ type SeatsUsed { total: Int } -type NetworkAccessPolicyType { +type OrganisationSSOProviderType { id: String! + providerType: ApiOrganisationSSOProviderProviderTypeChoices! name: String! - organisation: OrganisationType! - - """ - Comma-separated list of IP addresses or CIDR ranges (e.g. 192.168.1.1, 10.0.0.0/24) - """ - allowedIps: String! - isGlobal: Boolean! + enabled: Boolean! createdAt: DateTime! - createdBy: OrganisationMemberType updatedAt: DateTime! + publicConfig: JSONString + createdBy: OrganisationMemberType updatedBy: OrganisationMemberType - members: [OrganisationMemberType!]! - serviceAccounts: [ServiceAccountType!] - organisationMembers: [OrganisationMemberType!] +} + +"""An enumeration.""" +enum ApiOrganisationSSOProviderProviderTypeChoices { + """Microsoft Entra ID""" + ENTRA_ID + + """Okta""" + OKTA } type OrganisationMemberType { @@ -342,6 +346,25 @@ type ServiceAccountTokenType { lastUsed: DateTime } +type NetworkAccessPolicyType { + id: String! + name: String! + organisation: OrganisationType! + + """ + Comma-separated list of IP addresses or CIDR ranges (e.g. 192.168.1.1, 10.0.0.0/24) + """ + allowedIps: String! + isGlobal: Boolean! + createdAt: DateTime! + createdBy: OrganisationMemberType + updatedAt: DateTime! + updatedBy: OrganisationMemberType + members: [OrganisationMemberType!]! + serviceAccounts: [ServiceAccountType!] + organisationMembers: [OrganisationMemberType!] +} + type IdentityType { id: String! organisation: OrganisationType! @@ -384,18 +407,6 @@ enum ApiSecretEventTypeChoices { CONFIG } -"""An enumeration.""" -enum ApiSecretEventTypeChoices { - """Secret""" - SECRET - - """Sealed""" - SEALED - - """Config""" - CONFIG -} - """An enumeration.""" enum ApiSecretEventEventTypeChoices { """Create""" @@ -1063,6 +1074,11 @@ type Mutation { createIdentity(defaultTtlSeconds: Int!, description: String, maxTtlSeconds: Int!, name: String!, organisationId: ID!, provider: String!, resource: String, signatureTtlSeconds: Int, stsEndpoint: String, tenantId: String, tokenNamePattern: String, trustedPrincipals: String!): CreateIdentityMutation updateIdentity(defaultTtlSeconds: Int, description: String, id: ID!, maxTtlSeconds: Int, name: String, resource: String, signatureTtlSeconds: Int, stsEndpoint: String, tenantId: String, tokenNamePattern: String, trustedPrincipals: String): UpdateIdentityMutation deleteIdentity(id: ID!): DeleteIdentityMutation + createOrganisationSsoProvider(config: JSONString!, name: String!, orgId: ID!, providerType: String!): CreateOrganisationSSOProviderMutation + updateOrganisationSsoProvider(config: JSONString, enabled: Boolean, name: String, providerId: ID!): UpdateOrganisationSSOProviderMutation + deleteOrganisationSsoProvider(providerId: ID!): DeleteOrganisationSSOProviderMutation + testOrganisationSsoProvider(providerId: ID!): TestOrganisationSSOProviderMutation + updateOrganisationSecurity(orgId: ID!, requireSso: Boolean!): UpdateOrganisationSecurityMutation createServiceAccount(handlers: [ServiceAccountHandlerInput], identityKey: String, name: String, organisationId: ID, roleId: ID, serverWrappedKeyring: String, serverWrappedRecovery: String): CreateServiceAccountMutation enableServiceAccountServerSideKeyManagement(serverWrappedKeyring: String, serverWrappedRecovery: String, serviceAccountId: ID): EnableServiceAccountServerSideKeyManagementMutation enableServiceAccountClientSideKeyManagement(serviceAccountId: ID): EnableServiceAccountClientSideKeyManagementMutation @@ -1308,6 +1324,28 @@ type DeleteIdentityMutation { ok: Boolean } +type CreateOrganisationSSOProviderMutation { + providerId: ID +} + +type UpdateOrganisationSSOProviderMutation { + ok: Boolean +} + +type DeleteOrganisationSSOProviderMutation { + ok: Boolean +} + +type TestOrganisationSSOProviderMutation { + success: Boolean + error: String +} + +type UpdateOrganisationSecurityMutation { + ok: Boolean + sessionInvalidated: Boolean +} + type CreateServiceAccountMutation { serviceAccount: ServiceAccountType } diff --git a/frontend/graphql/mutations/sso/createOrgSSOProvider.gql b/frontend/graphql/mutations/sso/createOrgSSOProvider.gql new file mode 100644 index 000000000..8f2c7fcfb --- /dev/null +++ b/frontend/graphql/mutations/sso/createOrgSSOProvider.gql @@ -0,0 +1,15 @@ +mutation CreateOrgSSOProvider( + $orgId: ID! + $providerType: String! + $name: String! + $config: JSONString! +) { + createOrganisationSsoProvider( + orgId: $orgId + providerType: $providerType + name: $name + config: $config + ) { + providerId + } +} diff --git a/frontend/graphql/mutations/sso/deleteOrgSSOProvider.gql b/frontend/graphql/mutations/sso/deleteOrgSSOProvider.gql new file mode 100644 index 000000000..b39ffee3f --- /dev/null +++ b/frontend/graphql/mutations/sso/deleteOrgSSOProvider.gql @@ -0,0 +1,5 @@ +mutation DeleteOrgSSOProvider($providerId: ID!) { + deleteOrganisationSsoProvider(providerId: $providerId) { + ok + } +} diff --git a/frontend/graphql/mutations/sso/testOrgSSOProvider.gql b/frontend/graphql/mutations/sso/testOrgSSOProvider.gql new file mode 100644 index 000000000..9950efa14 --- /dev/null +++ b/frontend/graphql/mutations/sso/testOrgSSOProvider.gql @@ -0,0 +1,6 @@ +mutation TestOrgSSOProvider($providerId: ID!) { + testOrganisationSsoProvider(providerId: $providerId) { + success + error + } +} diff --git a/frontend/graphql/mutations/sso/updateOrgSSOProvider.gql b/frontend/graphql/mutations/sso/updateOrgSSOProvider.gql new file mode 100644 index 000000000..0e6ba96fa --- /dev/null +++ b/frontend/graphql/mutations/sso/updateOrgSSOProvider.gql @@ -0,0 +1,15 @@ +mutation UpdateOrgSSOProvider( + $providerId: ID! + $name: String + $config: JSONString + $enabled: Boolean +) { + updateOrganisationSsoProvider( + providerId: $providerId + name: $name + config: $config + enabled: $enabled + ) { + ok + } +} diff --git a/frontend/graphql/mutations/sso/updateOrgSecurity.gql b/frontend/graphql/mutations/sso/updateOrgSecurity.gql new file mode 100644 index 000000000..add8fb080 --- /dev/null +++ b/frontend/graphql/mutations/sso/updateOrgSecurity.gql @@ -0,0 +1,6 @@ +mutation UpdateOrgSecurity($orgId: ID!, $requireSso: Boolean!) { + updateOrganisationSecurity(orgId: $orgId, requireSso: $requireSso) { + ok + sessionInvalidated + } +} diff --git a/frontend/graphql/queries/getOrganisations.gql b/frontend/graphql/queries/getOrganisations.gql index a2bc9d013..15960df04 100644 --- a/frontend/graphql/queries/getOrganisations.gql +++ b/frontend/graphql/queries/getOrganisations.gql @@ -27,5 +27,11 @@ query GetOrganisations { keyring recovery pricingVersion + requireSso + ssoProviders { + name + providerType + enabled + } } } diff --git a/frontend/graphql/queries/sso/getOrgSSOProviders.gql b/frontend/graphql/queries/sso/getOrgSSOProviders.gql new file mode 100644 index 000000000..0c1f110db --- /dev/null +++ b/frontend/graphql/queries/sso/getOrgSSOProviders.gql @@ -0,0 +1,27 @@ +query GetOrgSSOProviders { + organisations { + id + name + requireSso + ssoProviders { + id + providerType + name + publicConfig + enabled + createdAt + createdBy { + fullName + avatarUrl + self + } + updatedAt + updatedBy { + fullName + avatarUrl + self + } + } + } + serverPublicKey +} From d6e2053e45b9cd52a7731965ed4a5e9d3002613a Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:21:50 +0800 Subject: [PATCH 051/100] feat(frontend): add SSO settings page with provider setup dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Single Sign-On tab in the access layout (between Roles and External Identities) - /[team]/access/sso redirects to /[team]/access/sso/oidc - Main page shows Active Provider, Enforce SSO toggle, configured provider cards (client_id/tenant_id as copyable ghost buttons, audit trail with avatars), and cards for the available providers (Entra ID, Okta) for first-time setup - EntraIDSetup + OktaSetup dialogs: tenant_id/issuer, client_id, and client_secret (encrypted client-side with encryptAsymmetric before submission). Edit dialog keeps the existing secret if left blank and shows a bullet placeholder of realistic length so the admin sees something is stored. - Enforce SSO dialog: * Info alert recommending the admin test the provider first * Bulleted effect summary (password disabled, other SSO providers disabled, members sign in via {activeProvider.name}, email matching) * Acknowledgement checkbox in a warning Alert — enforcing ends the current session and could lock the admin out * Enforce button disabled until the checkbox is ticked * On success with sessionInvalidated=true, redirects to /login?sso_enforced=true for a clean re-auth - Plan gate: if the org isn't on the Enterprise plan, the page renders UpsellDialog instead of the config UI. Runs before the permission check so non-admins also see a clear plan reason. - Deactivate + Delete confirmation dialogs, with active-provider warnings --- frontend/app/[team]/access/layout.tsx | 4 + frontend/app/[team]/access/sso/layout.tsx | 13 + frontend/app/[team]/access/sso/oidc/page.tsx | 718 ++++++++++++++++++ frontend/app/[team]/access/sso/page.tsx | 7 + .../components/access/sso/EntraIDSetup.tsx | 178 +++++ frontend/components/access/sso/OktaSetup.tsx | 182 +++++ 6 files changed, 1102 insertions(+) create mode 100644 frontend/app/[team]/access/sso/layout.tsx create mode 100644 frontend/app/[team]/access/sso/oidc/page.tsx create mode 100644 frontend/app/[team]/access/sso/page.tsx create mode 100644 frontend/components/access/sso/EntraIDSetup.tsx create mode 100644 frontend/components/access/sso/OktaSetup.tsx diff --git a/frontend/app/[team]/access/layout.tsx b/frontend/app/[team]/access/layout.tsx index 1881becce..8d52ca9a6 100644 --- a/frontend/app/[team]/access/layout.tsx +++ b/frontend/app/[team]/access/layout.tsx @@ -31,6 +31,10 @@ export default function AccessLayout({ name: 'Roles', link: 'roles', }, + { + name: 'Single Sign-On', + link: 'sso', + }, { name: 'External Identities', link: 'identities', diff --git a/frontend/app/[team]/access/sso/layout.tsx b/frontend/app/[team]/access/sso/layout.tsx new file mode 100644 index 000000000..53c52b317 --- /dev/null +++ b/frontend/app/[team]/access/sso/layout.tsx @@ -0,0 +1,13 @@ +'use client' + +// Side-nav commented out — only one section (OIDC) for now. +// Uncomment and add tabs when SAML/LDAP sections are added. + +export default function SSOLayout({ + children, +}: { + params: { team: string } + children: React.ReactNode +}) { + return
{children}
+} diff --git a/frontend/app/[team]/access/sso/oidc/page.tsx b/frontend/app/[team]/access/sso/oidc/page.tsx new file mode 100644 index 000000000..5d7b150e1 --- /dev/null +++ b/frontend/app/[team]/access/sso/oidc/page.tsx @@ -0,0 +1,718 @@ +'use client' + +import { useContext, useRef, useState } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { organisationContext } from '@/contexts/organisationContext' +import { useUser } from '@/contexts/userContext' +import { userHasPermission } from '@/utils/access/permissions' +import { GetOrgSSOProviders } from '@/graphql/queries/sso/getOrgSSOProviders.gql' +import { UpdateOrgSSOProvider } from '@/graphql/mutations/sso/updateOrgSSOProvider.gql' +import { DeleteOrgSSOProvider } from '@/graphql/mutations/sso/deleteOrgSSOProvider.gql' +import { UpdateOrgSecurity } from '@/graphql/mutations/sso/updateOrgSecurity.gql' +import { Alert } from '@/components/common/Alert' +import { EmptyState } from '@/components/common/EmptyState' +import GenericDialog from '@/components/common/GenericDialog' +import { Button } from '@/components/common/Button' +import { EntraIDSetup } from '@/components/access/sso/EntraIDSetup' +import { OktaSetup } from '@/components/access/sso/OktaSetup' +import { EntraIDLogo, OktaLogo } from '@/components/common/logos' +import CopyButton from '@/components/common/CopyButton' +import { toast } from 'react-toastify' +import { relativeTimeFromDates } from '@/utils/time' +import { Avatar } from '@/components/common/Avatar' +import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' +import { PlanLabel } from '@/components/settings/organisation/PlanLabel' +import { ApiOrganisationPlanChoices } from '@/apollo/graphql' +import { + FaBan, + FaCheckCircle, + FaShieldAlt, + FaTrashAlt, + FaPen, + FaSignInAlt, + FaToggleOn, + FaToggleOff, +} from 'react-icons/fa' + +const PROVIDER_INFO = { + entra_id: { + name: 'Microsoft Entra ID', + description: 'OIDC authentication via Microsoft Entra ID (Azure AD)', + icon: EntraIDLogo, + }, + okta: { + name: 'Okta', + description: 'OIDC authentication via Okta', + icon: OktaLogo, + }, +} as const + +type ProviderType = keyof typeof PROVIDER_INFO + +export default function OIDCPage({ params }: { params: { team: string } }) { + const { activeOrganisation: organisation } = useContext(organisationContext) + const { user } = useUser() + + const userCanReadSSO = organisation + ? userHasPermission(organisation?.role?.permissions, 'SSO', 'read') + : false + + const userCanManageSSO = organisation + ? userHasPermission(organisation?.role?.permissions, 'SSO', 'create') + : false + + const { data, refetch } = useQuery(GetOrgSSOProviders, { + skip: !organisation || !userCanReadSSO, + }) + + // Find this org's data from the organisations list + const orgData = data?.organisations?.find( + (o: any) => o.id === organisation?.id + ) + const ssoProviders = orgData?.ssoProviders || [] + const requireSso = orgData?.requireSso || false + const serverPublicKey = data?.serverPublicKey || '' + + const [setupProvider, setSetupProvider] = useState(null) + const [editingProvider, setEditingProvider] = useState(null) + + const setupDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const deleteDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const enforceDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const disableDialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const testSSODialogRef = useRef<{ openModal: () => void; closeModal: () => void }>(null) + const [deletingProvider, setDeletingProvider] = useState(null) + const [testingProvider, setTestingProvider] = useState(null) + const [enforceAck, setEnforceAck] = useState(false) + + const [updateProvider] = useMutation(UpdateOrgSSOProvider) + const [deleteProvider] = useMutation(DeleteOrgSSOProvider) + const [updateSecurity] = useMutation(UpdateOrgSecurity) + + const handleSetup = (type: ProviderType) => { + setSetupProvider(type) + setEditingProvider(null) + setupDialogRef.current?.openModal() + } + + const handleEdit = (provider: any) => { + const type = normalizeType(provider.providerType) + setSetupProvider(type) + // Parse publicConfig if it's a string so the dialog can read field values + const parsed = { + ...provider, + publicConfig: + typeof provider.publicConfig === 'string' + ? JSON.parse(provider.publicConfig) + : provider.publicConfig || {}, + } + setEditingProvider(parsed) + setupDialogRef.current?.openModal() + } + + const handleSetupSuccess = () => { + setupDialogRef.current?.closeModal() + setSetupProvider(null) + setEditingProvider(null) + refetch() + } + + const handleToggleEnabled = async (provider: any) => { + try { + await updateProvider({ + variables: { + providerId: provider.id, + enabled: !provider.enabled, + }, + }) + toast.success(provider.enabled ? 'Provider deactivated' : 'Provider activated') + refetch() + } catch (err: any) { + toast.error(err?.message || 'Failed to update provider') + } + } + + const handleDelete = async () => { + if (!deletingProvider) return + try { + await deleteProvider({ + variables: { providerId: deletingProvider.id }, + }) + toast.success('SSO provider deleted') + deleteDialogRef.current?.closeModal() + setDeletingProvider(null) + refetch() + } catch (err: any) { + toast.error(err?.message || 'Failed to delete provider') + } + } + + const handleTestSSO = (provider: any) => { + const callbackUrl = `/${params.team}/access/sso/oidc?sso_test=${provider.id}` + window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/auth/sso/org/${provider.id}/authorize/?callbackUrl=${encodeURIComponent(callbackUrl)}` + } + + const handleToggleEnforcement = async () => { + try { + const result = await updateSecurity({ + variables: { + orgId: organisation?.id, + requireSso: !requireSso, + }, + }) + if (result.data?.updateOrganisationSecurity?.sessionInvalidated) { + // Backend killed this admin's session because they enforced SSO + // without being SSO-authenticated themselves. Skip the success + // toast here (it would flash for 0ms before the redirect) and + // carry the message on the /login page instead via ?sso_enforced. + window.location.href = '/login?sso_enforced=true' + return + } + toast.success(requireSso ? 'SSO enforcement disabled' : 'SSO enforcement enabled') + refetch() + } catch (err: any) { + toast.error(err?.message || 'Failed to update SSO enforcement') + } + } + + const openEnforceDialog = () => { + setEnforceAck(false) + enforceDialogRef.current?.openModal() + } + + // State 1: Plan gate (checked before permissions, so non-admin users + // also see the upgrade prompt rather than "access restricted" — clearer + // signal about *why* SSO is unavailable). Self-hosted orgs without an + // Enterprise license also land here; UpsellDialog shows contact-us copy. + const planAllowsSSO = + organisation?.plan === ApiOrganisationPlanChoices.En + + if (organisation && !planAllowsSSO) { + return ( +
+
+

OIDC Providers

+

+ Configure OIDC single sign-on for your organisation. +

+
+ + +
+ } + > +
+ + Upgrade + + + } + /> +
+ +
+ ) + } + + // State 2: Access denied + if (!userCanReadSSO) { + return ( + + +
+ } + > + <> + + ) + } + + // Normalize providerType from GraphQL (ENTRA_ID) to match our keys (entra_id) + const normalizeType = (t: string) => t.toLowerCase() as ProviderType + + // Determine which provider types are already configured + const configuredTypes = new Set(ssoProviders.map((p: any) => normalizeType(p.providerType))) + const availableProviders = (Object.keys(PROVIDER_INFO) as ProviderType[]).filter( + (type) => !configuredTypes.has(type) + ) + const activeProvider = ssoProviders.find((p: any) => p.enabled) + + return ( +
+
+

OIDC Providers

+

+ Configure OIDC single sign-on for your organisation. +

+
+ + {/* Active provider + Enforce SSO */} +
+
+ Active Provider: + {activeProvider ? ( + + + {activeProvider.name} + + ) : ( + None + )} +
+ + {userCanManageSSO && ( +
+ + + {requireSso + ? 'SSO is required for all members' + : 'Require all members to sign in via SSO'} + +
+ )} +
+ + {/* Configured providers */} + {ssoProviders.length > 0 && ( +
+ {ssoProviders.map((provider: any) => { + const info = PROVIDER_INFO[normalizeType(provider.providerType)] + if (!info) return null + const Icon = info.icon + const parsedConfig = + typeof provider.publicConfig === 'string' + ? JSON.parse(provider.publicConfig) + : provider.publicConfig || {} + + return ( +
+
+
+ +
+
+ {provider.name} + {provider.enabled && ( + + Active + + )} +
+
{info.name}
+
+
+ + {userCanManageSSO && ( +
+ + + + +
+ )} +
+ + {/* Config details — copyable */} + {Object.keys(parsedConfig).length > 0 && ( +
+ {Object.entries(parsedConfig).map(([key, value]) => ( + + + {key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}:{' '} + + + {value as string} + + + ))} +
+ )} + + {/* Audit trail */} +
+ {provider.createdBy && ( +
+ Added + {relativeTimeFromDates(new Date(provider.createdAt))} + by + + {provider.createdBy.fullName} +
+ )} + {provider.updatedBy && provider.updatedAt !== provider.createdAt && ( +
+ Updated + {relativeTimeFromDates(new Date(provider.updatedAt))} + by + + {provider.updatedBy.fullName} +
+ )} +
+
+ ) + })} +
+ )} + + {/* Available providers (empty state / add more) */} + {availableProviders.length > 0 && ( +
+ {ssoProviders.length > 0 && ( +

Add Provider

+ )} + {ssoProviders.length === 0 && availableProviders.length > 0 ? ( + + +
+ } + > +
+ {availableProviders.map((type) => { + const info = PROVIDER_INFO[type] + const Icon = info.icon + return ( + + ) + })} +
+ + ) : ( +
+ {availableProviders.map((type) => { + const info = PROVIDER_INFO[type] + const Icon = info.icon + return ( + + ) + })} +
+ )} +
+ )} + + {/* Setup Dialog */} + + {setupProvider === 'entra_id' && ( + setupDialogRef.current?.closeModal()} + /> + )} + {setupProvider === 'okta' && ( + setupDialogRef.current?.closeModal()} + /> + )} + + + {/* Delete Confirmation Dialog */} + +
+

+ Are you sure you want to delete{' '} + {deletingProvider?.name}? + {deletingProvider?.enabled && ( + + {' '} + This provider is currently active. Members using SSO will need to use password + login. + + )} +

+
+ + +
+
+
+ + {/* Enforce SSO Confirmation Dialog */} + +
+
+

+ Once enforced, this takes effect immediately: +

+
    +
  • Password login will be disabled for all new and existing members.
  • +
  • Sign-in via other SSO providers (Google, GitHub, etc.) will also be disabled.
  • +
  • + All members will be required to authenticate via{' '} + + {activeProvider?.name || 'your SSO provider'} + + . +
  • +
  • Users are matched to their existing accounts by email address.
  • +
+
+ + +

+ Before enforcing SSO, make sure you have tested the provider and + signed in successfully at least once. +

+
+ + + + + +
+ + +
+
+
+ + {/* Test SSO Confirmation Dialog */} + +
+ {testingProvider?.enabled ? ( + <> +
+

+ You will be redirected to your identity provider to complete a test + authentication. Once complete, you will be sent back to Phase Console. +

+ +

+ Make sure you sign in with{' '} + {user?.email}{' '} + at your identity provider. If you use a different email, a new account will + be created and you will be logged out of your current session. +

+
+
+
+ + +
+ + ) : ( + <> + +

+ You need to activate this provider before you can test it. +

+
+
+ +
+ + )} +
+
+ + {/* Disable Provider Confirmation Dialog */} + +
+
+

+ Are you sure you want to deactivate{' '} + + {deletingProvider?.name} + + ? +

+
    +
  • + Members who signed in via this provider will not be able to log in until + another SSO provider is enabled or they reset their password. +
  • + {requireSso && ( +
  • + SSO enforcement is currently active — deactivating this provider will also + turn off enforcement, allowing password login. +
  • + )} +
+
+
+ + +
+
+
+ + ) +} diff --git a/frontend/app/[team]/access/sso/page.tsx b/frontend/app/[team]/access/sso/page.tsx new file mode 100644 index 000000000..d8321609f --- /dev/null +++ b/frontend/app/[team]/access/sso/page.tsx @@ -0,0 +1,7 @@ +'use client' + +import { redirect } from 'next/navigation' + +export default function SSOPage({ params }: { params: { team: string } }) { + redirect(`/${params.team}/access/sso/oidc`) +} diff --git a/frontend/components/access/sso/EntraIDSetup.tsx b/frontend/components/access/sso/EntraIDSetup.tsx new file mode 100644 index 000000000..56bac4232 --- /dev/null +++ b/frontend/components/access/sso/EntraIDSetup.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState } from 'react' +import { Input } from '@/components/common/Input' +import { Button } from '@/components/common/Button' +import CopyButton from '@/components/common/CopyButton' +import { encryptAsymmetric } from '@/utils/crypto/general' +import { useMutation } from '@apollo/client' +import { CreateOrgSSOProvider } from '@/graphql/mutations/sso/createOrgSSOProvider.gql' +import { UpdateOrgSSOProvider } from '@/graphql/mutations/sso/updateOrgSSOProvider.gql' +import { toast } from 'react-toastify' +import { getHostname } from '@/utils/appConfig' +import { Alert } from '@/components/common/Alert' +import { FaExternalLinkAlt } from 'react-icons/fa' + +interface EntraIDSetupProps { + orgId: string + serverPublicKey: string + existingProvider?: { + id: string + name: string + publicConfig: Record + } | null + onSuccess: () => void + onCancel: () => void +} + +export const EntraIDSetup = ({ + orgId, + serverPublicKey, + existingProvider, + onSuccess, + onCancel, +}: EntraIDSetupProps) => { + const isEditing = !!existingProvider + + const initialName = existingProvider?.name || 'Microsoft Entra ID' + const initialTenantId = existingProvider?.publicConfig?.tenant_id || '' + const initialClientId = existingProvider?.publicConfig?.client_id || '' + + const [name, setName] = useState(initialName) + const [tenantId, setTenantId] = useState(initialTenantId) + const [clientId, setClientId] = useState(initialClientId) + const [clientSecret, setClientSecret] = useState('') + const [saving, setSaving] = useState(false) + + const hasChanges = !isEditing || + name !== initialName || + tenantId !== initialTenantId || + clientId !== initialClientId || + clientSecret !== '' + + const [createProvider] = useMutation(CreateOrgSSOProvider) + const [updateProvider] = useMutation(UpdateOrgSSOProvider) + + const redirectUri = `${getHostname()}/api/auth/callback/entra-id-oidc` + + const handleSave = async () => { + if (!tenantId || !clientId || (!isEditing && !clientSecret)) { + toast.error('Please fill in all required fields') + return + } + + // Validate tenant ID is a UUID + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + if (!uuidRegex.test(tenantId)) { + toast.error('Tenant ID must be a valid UUID') + return + } + + setSaving(true) + try { + const config: Record = { + tenant_id: tenantId, + client_id: clientId, + } + + // Only encrypt and include client_secret if provided + if (clientSecret) { + config.client_secret = await encryptAsymmetric(clientSecret, serverPublicKey) + } + + if (isEditing) { + await updateProvider({ + variables: { + providerId: existingProvider!.id, + name, + config: JSON.stringify(config), + }, + }) + toast.success('SSO provider updated') + } else { + await createProvider({ + variables: { + orgId, + providerType: 'entra_id', + name, + config: JSON.stringify(config), + }, + }) + toast.success('SSO provider configured') + } + + onSuccess() + } catch (err: any) { + toast.error(err?.message || 'Failed to save SSO configuration') + } finally { + setSaving(false) + } + } + + return ( +
+ +
+

+ To configure Entra ID OIDC, register a new application in the{' '} + + Azure Portal + +

+

Add the following redirect URI to your app registration:

+
+
+ +
+ +
+ + {redirectUri} + + +
+
+ + + + + + + + + +
+ + +
+
+ ) +} diff --git a/frontend/components/access/sso/OktaSetup.tsx b/frontend/components/access/sso/OktaSetup.tsx new file mode 100644 index 000000000..06328ec91 --- /dev/null +++ b/frontend/components/access/sso/OktaSetup.tsx @@ -0,0 +1,182 @@ +'use client' + +import { useState } from 'react' +import { Input } from '@/components/common/Input' +import { Button } from '@/components/common/Button' +import CopyButton from '@/components/common/CopyButton' +import { encryptAsymmetric } from '@/utils/crypto/general' +import { useMutation } from '@apollo/client' +import { CreateOrgSSOProvider } from '@/graphql/mutations/sso/createOrgSSOProvider.gql' +import { UpdateOrgSSOProvider } from '@/graphql/mutations/sso/updateOrgSSOProvider.gql' +import { toast } from 'react-toastify' +import { getHostname } from '@/utils/appConfig' +import { Alert } from '@/components/common/Alert' +import { FaExternalLinkAlt } from 'react-icons/fa' + +interface OktaSetupProps { + orgId: string + serverPublicKey: string + existingProvider?: { + id: string + name: string + publicConfig: Record + } | null + onSuccess: () => void + onCancel: () => void +} + +export const OktaSetup = ({ + orgId, + serverPublicKey, + existingProvider, + onSuccess, + onCancel, +}: OktaSetupProps) => { + const isEditing = !!existingProvider + + const initialName = existingProvider?.name || 'Okta' + const initialIssuer = existingProvider?.publicConfig?.issuer || '' + const initialClientId = existingProvider?.publicConfig?.client_id || '' + + const [name, setName] = useState(initialName) + const [issuer, setIssuer] = useState(initialIssuer) + const [clientId, setClientId] = useState(initialClientId) + const [clientSecret, setClientSecret] = useState('') + const [saving, setSaving] = useState(false) + + const hasChanges = !isEditing || + name !== initialName || + issuer !== initialIssuer || + clientId !== initialClientId || + clientSecret !== '' + + const [createProvider] = useMutation(CreateOrgSSOProvider) + const [updateProvider] = useMutation(UpdateOrgSSOProvider) + + const redirectUri = `${getHostname()}/api/auth/callback/okta-oidc` + + const handleSave = async () => { + if (!issuer || !clientId || (!isEditing && !clientSecret)) { + toast.error('Please fill in all required fields') + return + } + + // Validate issuer URL format + try { + const url = new URL(issuer) + if (url.protocol !== 'https:') { + toast.error('Issuer URL must use HTTPS') + return + } + } catch { + toast.error('Invalid issuer URL') + return + } + + setSaving(true) + try { + const config: Record = { + issuer: issuer.replace(/\/$/, ''), + client_id: clientId, + } + + if (clientSecret) { + config.client_secret = await encryptAsymmetric(clientSecret, serverPublicKey) + } + + if (isEditing) { + await updateProvider({ + variables: { + providerId: existingProvider!.id, + name, + config: JSON.stringify(config), + }, + }) + toast.success('SSO provider updated') + } else { + await createProvider({ + variables: { + orgId, + providerType: 'okta', + name, + config: JSON.stringify(config), + }, + }) + toast.success('SSO provider configured') + } + + onSuccess() + } catch (err: any) { + toast.error(err?.message || 'Failed to save SSO configuration') + } finally { + setSaving(false) + } + } + + return ( +
+ +
+

+ Create an OIDC application in your{' '} + + Okta admin console + +

+

Add the following redirect URI to your Okta application:

+
+
+ +
+ +
+ + {redirectUri} + + +
+
+ + + + + + + + + +
+ + +
+
+ ) +} From 0b34aae511c2811e9c5e1dedd7087433932a6f28 Mon Sep 17 00:00:00 2001 From: Nimish Date: Fri, 17 Apr 2026 21:23:20 +0800 Subject: [PATCH 052/100] feat(frontend): surface SSO in login, lobby, and global error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SignInButtons: email-first flow now shows any org-level SSO providers the user can sign in through alongside the password field (or alone, if the user has no password), matching the new email_check response shape. When ?sso_enforced=true is on the URL (redirect target after an admin enables enforcement), an info toast explains the user needs to sign in via SSO to continue. - userContext: carries authSsoOrgId from auth_me so the UI can tell when the current session is bound to a specific org's SSO flow. - app/page.tsx (org lobby): orgs with require_sso=True show a lockout state when the current session isn't SSO-bound to that org — "Sign in with to access" badge plus a "Log out and sign in with SSO" link. Non-enforced orgs still enter normally. - apollo/client.ts: global errorLink recognises SSO_REQUIRED alongside IP_RESTRICTED. Users hitting an enforced resolver from a non-SSO session get bounced back to / (the lobby), which renders the lockout state — matching the IP_RESTRICTED redirect pattern so bookmarked deep links into enforced orgs land on a useful page instead of a blank toast. --- frontend/apollo/client.ts | 13 ++ frontend/app/page.tsx | 65 +++++++-- frontend/components/auth/SignInButtons.tsx | 159 ++++++++++++++++++--- frontend/contexts/userContext.tsx | 1 + 4 files changed, 209 insertions(+), 29 deletions(-) diff --git a/frontend/apollo/client.ts b/frontend/apollo/client.ts index 6fdfd7650..ad728a9ad 100644 --- a/frontend/apollo/client.ts +++ b/frontend/apollo/client.ts @@ -38,6 +38,19 @@ const errorLink = onError(({ graphQLErrors, networkError }) => { return } + if (code === 'SSO_REQUIRED') { + // Org requires SSO and the current session was not established via + // the org's SSO flow. Send the user back to the lobby where the org + // card surfaces the "Sign in with " prompt. Avoid a redirect + // loop if we're already at the lobby. + if (window.location.pathname !== '/') { + window.location.href = '/' + } else { + toast.error(err.message) + } + return + } + // Default error handling (toast) toast.error(err.message) console.log( diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 9c6e9287e..da2fb32c1 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -5,8 +5,9 @@ import { useContext, useEffect, useState } from 'react' import Loading from './loading' import { OrganisationType } from '@/apollo/graphql' import { organisationContext } from '@/contexts/organisationContext' +import { useUser } from '@/contexts/userContext' import { Button } from '@/components/common/Button' -import { FaArrowRight, FaUsers } from 'react-icons/fa' +import { FaArrowRight, FaLock, FaSignOutAlt, FaUsers } from 'react-icons/fa' import { RoleLabel } from '@/components/users/RoleLabel' import OnboardingNavbar from '@/components/layout/OnboardingNavbar' @@ -15,6 +16,7 @@ import { useQuery } from '@apollo/client' import { Card } from '@/components/common/Card' import { PlanLabel } from '@/components/settings/organisation/PlanLabel' import { FaCubes } from 'react-icons/fa6' +import { handleSignout } from '@/apollo/client' export default function Home() { const router = useRouter() @@ -22,22 +24,41 @@ export default function Home() { useQuery(GetLicenseData) const { organisations, setActiveOrganisation, loading } = useContext(organisationContext) + const { user } = useUser() const [showOrgCards, setShowOrgCards] = useState(false) + const canAccessOrg = (org: OrganisationType) => { + if (!org.requireSso) return true + if (user?.authMethod === 'sso' && user?.authSsoOrgId === org.id) return true + return false + } + + const getActiveProviderName = (org: OrganisationType) => { + const active = org.ssoProviders?.find((p) => p?.enabled) + return active?.name || 'SSO' + } + const handleRouteToOrg = (org: OrganisationType) => { + if (!canAccessOrg(org)) return router.push(`/${org!.name}`) } useEffect(() => { - if (!loading && organisations !== null) { + if (!loading && organisations !== null && user !== null) { // if there is no org membership, send to onboarding if (organisations.length === 0) router.push('/onboard') // if there is a single org membership, send to org home else if (organisations.length === 1) { const organisation = organisations[0] - setActiveOrganisation(organisation) - router.push(`/${organisation!.name}`) + if (canAccessOrg(organisation)) { + setActiveOrganisation(organisation) + router.push(`/${organisation!.name}`) + } else { + // Single org but can't access (SSO required) — show card so user sees the message + setActiveOrganisation(null) + setShowOrgCards(true) + } } // if there are multiple memberships, show orgs @@ -47,7 +68,7 @@ export default function Home() { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [organisations, router, loading]) + }, [organisations, router, loading, user]) return (
@@ -62,10 +83,12 @@ export default function Home() {

Choose an organisation

- {organisations!.map((org: OrganisationType) => ( -
handleRouteToOrg(org)}> + {organisations!.map((org: OrganisationType) => { + const accessible = canAccessOrg(org) + return ( +
handleRouteToOrg(org)}> -
+

@@ -104,15 +127,31 @@ export default function Home() { )}

-
- +
+ {accessible ? ( + + ) : ( +
+
+ + Sign in with {getActiveProviderName(org)} to access +
+ +
+ )}
- ))} + )})}
diff --git a/frontend/components/auth/SignInButtons.tsx b/frontend/components/auth/SignInButtons.tsx index 3b1732193..185dad72a 100644 --- a/frontend/components/auth/SignInButtons.tsx +++ b/frontend/components/auth/SignInButtons.tsx @@ -47,6 +47,22 @@ const providerButtons: ProviderButton[] = [ { id: 'okta-oidc', name: 'Okta', icon: OktaLogo }, ] +// Map org-level provider_type to the icon used for instance-level buttons +const orgProviderIcons: Record React.ReactNode> = { + entra_id: EntraIDLogo, + okta: OktaLogo, + google: GoogleLogo, + jumpcloud: JumpCloudLogo, +} + +type SSOMethod = { + id: string + providerType: 'instance' | 'oidc' + provider?: string + providerName?: string + enforced: boolean +} + type LoginStep = 'email' | 'password' | 'sso-redirect' export default function SignInButtons({ @@ -68,6 +84,8 @@ export default function SignInButtons({ const [showPw, setShowPw] = useState(false) const [step, setStep] = useState('email') const [ssoProvider, setSsoProvider] = useState(null) + const [ssoMethods, setSsoMethods] = useState([]) + const [hasPassword, setHasPassword] = useState(true) const passwordRef = useRef(null) @@ -95,13 +113,30 @@ export default function SignInButtons({ { withCredentials: true } ) - const { authMethod, ssoProvider: provider } = response.data - - if (authMethod === 'sso' && provider) { - setSsoProvider(provider) + const { authMethods, authMethod, ssoProvider: legacyProvider } = response.data + + if (authMethods) { + const methods = authMethods.sso as SSOMethod[] + const passwordAvailable = authMethods.password as boolean + setHasPassword(passwordAvailable) + setSsoMethods(methods) + + if (methods.length > 0 && !passwordAvailable) { + // Only SSO available (no password set) — go straight to SSO + const method = methods[0] + setSsoProvider(method.id) + setStep('sso-redirect') + } else { + // Password available (possibly with SSO options too). + // SSO enforcement is handled at the org lobby, not the login page. + setStep('password') + setTimeout(() => passwordRef.current?.focus(), 100) + } + } else if (authMethod === 'sso' && legacyProvider) { + // Legacy response format + setSsoProvider(legacyProvider) setStep('sso-redirect') } else { - // "credentials" — show password field (works for both existing and unknown emails) setStep('password') setTimeout(() => passwordRef.current?.focus(), 100) } @@ -148,17 +183,27 @@ export default function SignInButtons({ setPassword('') setShowPw(false) setSsoProvider(null) + setSsoMethods([]) + setHasPassword(true) } useEffect(() => { const providerId = searchParams?.get('provider') const error = searchParams?.get('error') const verified = searchParams?.get('verified') + const ssoEnforced = searchParams?.get('sso_enforced') if (verified === 'true') { toast.success('Email verified! You can now log in.', { autoClose: 5000 }) } + if (ssoEnforced === 'true') { + toast.info( + 'SSO enforcement is now active for your organisation. Please sign in via SSO to continue.', + { autoClose: 8000 } + ) + } + if (error) { toast.error( 'Something went wrong. Please contact your server admin or check the server logs for more information.', @@ -273,7 +318,7 @@ export default function SignInButtons({
Create an account @@ -287,7 +332,7 @@ export default function SignInButtons({ + + {/* Show SSO option when both password and SSO are available */} + {ssoMethods.length > 0 && ( + <> +
+
+ or +
+
+ {ssoMethods.map((method) => { + const isOrg = method.providerType === 'oidc' + const handleClick = () => { + setLoading(true) + const callbackUrl = searchParams?.get('callbackUrl') || '' + const qs = callbackUrl + ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` + : '' + if (isOrg) { + window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/auth/sso/org/${method.id}/authorize/${qs}` + } else { + window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/auth/sso/${method.id}/authorize/${qs}` + } + } + + const label = isOrg + ? `Sign in with ${method.providerName || 'SSO'}` + : `Sign in with ${getProviderName(method.id)}` + const icon = isOrg + ? (method.provider ? orgProviderIcons[method.provider] : undefined) + : providerButtons.find((p) => p.id === method.id)?.icon + + return ( + + ) + })} + + )} +
Create an account @@ -340,7 +431,7 @@ export default function SignInButtons({ + {ssoMethods.length > 0 ? ( + ssoMethods.map((method) => { + const isOrg = method.providerType === 'oidc' + const handleClick = () => { + setLoading(true) + const callbackUrl = searchParams?.get('callbackUrl') || '' + const qs = callbackUrl + ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` + : '' + if (isOrg) { + window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/auth/sso/org/${method.id}/authorize/${qs}` + } else { + window.location.href = `${process.env.NEXT_PUBLIC_BACKEND_API_BASE}/auth/sso/${method.id}/authorize/${qs}` + } + } + + const label = isOrg + ? `Continue with ${method.providerName || 'SSO'}` + : `Continue with ${getProviderName(method.id)}` + const icon = isOrg + ? (method.provider ? orgProviderIcons[method.provider] : undefined) + : providerButtons.find((p) => p.id === method.id)?.icon + + return ( + + ) + }) + ) : ( + + )}
)}
diff --git a/frontend/contexts/userContext.tsx b/frontend/contexts/userContext.tsx index f498436b4..6c8761d45 100644 --- a/frontend/contexts/userContext.tsx +++ b/frontend/contexts/userContext.tsx @@ -11,6 +11,7 @@ interface UserData { fullName: string avatarUrl: string | null authMethod: 'password' | 'sso' + authSsoOrgId: string | null } interface UserContextValue { From a1cec6a36d3e0ec85b21693dab6b81adb011a0bf Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 18 Apr 2026 13:42:35 +0800 Subject: [PATCH 053/100] chore: ignore .env.dev.* variants .env.dev was exact-matched; .env.dev.test and similar scratch env files used for isolated testing weren't covered and could be accidentally committed. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c9ff6450..4ad171904 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,7 @@ yarn-error.log* # local env files .env*.local .env.dev +.env.dev.* # vercel .vercel From 2144437dd61438161595e39641999bfd5e9a287c Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 21 Apr 2026 15:15:10 +0530 Subject: [PATCH 054/100] fix(auth): correct email_check reverse accessor and surface org SSO on invite links --- backend/api/views/auth_password.py | 110 +++++++++++++++------ backend/backend/urls.py | 2 + frontend/components/auth/SignInButtons.tsx | 62 +++++++++++- 3 files changed, 142 insertions(+), 32 deletions(-) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index 27dd7e381..34c86e93f 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -35,10 +35,13 @@ class CsrfExemptSessionAuthentication(SessionAuthentication): def enforce_csrf(self, request): return # Skip CSRF check +from django.db.models import Q + from api.models import ( EmailVerification, Organisation, OrganisationMember, + OrganisationMemberInvite, OrganisationSSOProvider, ) @@ -443,6 +446,36 @@ def password_reset_via_recovery(request): return JsonResponse({"message": "Password reset successfully."}) +@csrf_exempt +@api_view(["GET"]) +@authentication_classes([]) +@permission_classes([AllowAny]) +@throttle_classes([EmailCheckThrottle]) +def invite_lookup(request, invite_id): + """Return the invitee email + org name for a valid pending invite. + + Used by the login page to prefill the email field when a user is + redirected there from an invite link. Invite IDs are UUID4 (122 bits + of entropy) so enumeration is infeasible; the EmailCheck throttle + adds an extra layer. + """ + try: + invite = OrganisationMemberInvite.objects.select_related( + "organisation" + ).get( + id=invite_id, + valid=True, + expires_at__gt=timezone.now(), + ) + except OrganisationMemberInvite.DoesNotExist: + return JsonResponse({"error": "Invite not found or expired."}, status=404) + + return JsonResponse({ + "inviteeEmail": invite.invitee_email, + "organisationName": invite.organisation.name, + }) + + @csrf_exempt @api_view(["POST"]) @authentication_classes([]) @@ -465,41 +498,62 @@ def email_check(request): User = get_user_model() email = (request.data.get("email") or "").lower().strip() + invite_id = request.data.get("inviteId") or request.data.get("invite_id") if not email: return JsonResponse({"error": "Email is required."}, status=400) - # Default: password user (minimal enumeration) - default_response = { - "authMethods": { - "password": True, - "sso": [], - } - } - + # Unknown users default to password=True to minimise enumeration. try: user = User.objects.get(email=email) + has_password = user.has_usable_password() except User.DoesNotExist: - return JsonResponse(default_response) - - has_password = user.has_usable_password() - - # SSO user — find which provider they used - org_providers = OrganisationSSOProvider.objects.filter( - organisation__organisationmember__user=user, - organisation__organisationmember__deleted_at=None, - enabled=True, - ).select_related("organisation").distinct() + user = None + has_password = True + + # Build the provider query from (a) the user's existing org memberships + # and (b) any pending invite they're resolving. Either, neither, or both + # may apply. + provider_filters = [] + if user is not None: + provider_filters.append( + Q( + organisation__users__user=user, + organisation__users__deleted_at=None, + ) + ) - sso_methods = [ - { - "id": str(provider.id), - "providerType": "oidc", - "provider": provider.provider_type, - "providerName": provider.name, - "enforced": provider.organisation.require_sso, - } - for provider in org_providers - ] + if invite_id: + try: + invite = OrganisationMemberInvite.objects.get( + id=invite_id, + valid=True, + expires_at__gt=timezone.now(), + invitee_email__iexact=email, + ) + provider_filters.append(Q(organisation=invite.organisation)) + except OrganisationMemberInvite.DoesNotExist: + pass + + sso_methods = [] + if provider_filters: + combined = provider_filters[0] + for q in provider_filters[1:]: + combined = combined | q + org_providers = ( + OrganisationSSOProvider.objects.filter(combined, enabled=True) + .select_related("organisation") + .distinct() + ) + sso_methods = [ + { + "id": str(provider.id), + "providerType": "oidc", + "provider": provider.provider_type, + "providerName": provider.name, + "enforced": provider.organisation.require_sso, + } + for provider in org_providers + ] return JsonResponse({ "authMethods": { diff --git a/backend/backend/urls.py b/backend/backend/urls.py index d014fb0e8..15ca53c60 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -26,6 +26,7 @@ verify_email, resend_verification, email_check, + invite_lookup, ) from api.views.identities.aws.iam import aws_iam_auth from api.views.identities.azure.entra import azure_entra_auth @@ -55,6 +56,7 @@ path("auth/verify-email/resend/", resend_verification), path("auth/verify-email//", verify_email), path("auth/email/check/", email_check), + path("auth/invite//", invite_lookup), # GraphQL API path("graphql/", csrf_exempt(PrivateGraphQLView.as_view(graphiql=True))), # OAuth integrations diff --git a/frontend/components/auth/SignInButtons.tsx b/frontend/components/auth/SignInButtons.tsx index 185dad72a..487f3c523 100644 --- a/frontend/components/auth/SignInButtons.tsx +++ b/frontend/components/auth/SignInButtons.tsx @@ -24,10 +24,25 @@ import { isCloudHosted } from '@/utils/appConfig' import { Alert } from '../common/Alert' import { FaArrowLeft } from 'react-icons/fa' import { FaEye, FaEyeSlash } from 'react-icons/fa' -import { deviceVaultKey, passwordAuthHash } from '@/utils/crypto' +import { decodeb64string, deviceVaultKey, passwordAuthHash } from '@/utils/crypto' import axios from 'axios' import { UrlUtils } from '@/utils/auth' +const INVITE_PATH_RE = /^\/invite\/([^/?#]+)/ + +const extractInviteIdFromCallback = async ( + callbackUrl: string | null | undefined +): Promise => { + if (!callbackUrl) return null + const match = callbackUrl.match(INVITE_PATH_RE) + if (!match) return null + try { + return await decodeb64string(decodeURIComponent(match[1])) + } catch { + return null + } +} + type ProviderButton = { id: string name: string @@ -80,6 +95,7 @@ export default function SignInButtons({ const searchParams = useSearchParams() const [email, setEmail] = useState('') + const [emailLocked, setEmailLocked] = useState(false) const [password, setPassword] = useState('') const [showPw, setShowPw] = useState(false) const [step, setStep] = useState('email') @@ -91,6 +107,35 @@ export default function SignInButtons({ const hasSSOProviders = providers.length > 0 + useEffect(() => { + let cancelled = false + const prefillFromInvite = async () => { + const inviteId = await extractInviteIdFromCallback(searchParams?.get('callbackUrl')) + if (!inviteId) return + try { + const { data } = await axios.get( + UrlUtils.makeUrl( + process.env.NEXT_PUBLIC_BACKEND_API_BASE!, + 'auth', + 'invite', + inviteId + ), + { withCredentials: true } + ) + if (!cancelled && data?.inviteeEmail) { + setEmail(data.inviteeEmail) + setEmailLocked(true) + } + } catch { + // Invalid/expired invite — leave the email blank, user can enter manually + } + } + prefillFromInvite() + return () => { + cancelled = true + } + }, [searchParams]) + const handleProviderButtonClick = useCallback( (providerId: string) => { setLoading(true) @@ -107,9 +152,13 @@ export default function SignInButtons({ setChecking(true) try { + const inviteId = await extractInviteIdFromCallback(searchParams?.get('callbackUrl')) const response = await axios.post( UrlUtils.makeUrl(process.env.NEXT_PUBLIC_BACKEND_API_BASE!, 'auth', 'email', 'check'), - { email: email.toLowerCase().trim() }, + { + email: email.toLowerCase().trim(), + ...(inviteId ? { inviteId } : {}), + }, { withCredentials: true } ) @@ -302,8 +351,13 @@ export default function SignInButtons({ value={email} onChange={(e) => setEmail(e.target.value)} required - autoFocus - className="w-full" + autoFocus={!emailLocked} + readOnly={emailLocked} + aria-readonly={emailLocked} + className={clsx( + 'w-full', + emailLocked && 'cursor-not-allowed opacity-75' + )} />
)} - {step === 1 && ( - - )} + {step === 1 && + (isPasswordUser ? ( +
+
+ +
+ setPw(e.target.value)} + type={showPw ? 'text' : 'password'} + required + className="w-full ph-no-capture" + autoFocus + /> + +
+
+ +
+
+ + + Remember password on this device + +
+ setSavePassword(!savePassword)} + /> +
+
+ ) : ( + + ))}
diff --git a/frontend/app/invite/[invite]/page.tsx b/frontend/app/invite/[invite]/page.tsx index ef4d6ceae..8b65be2ba 100644 --- a/frontend/app/invite/[invite]/page.tsx +++ b/frontend/app/invite/[invite]/page.tsx @@ -15,10 +15,10 @@ import { AccountRecovery } from '@/components/onboarding/AccountRecovery' import { MdKey, MdOutlinePassword } from 'react-icons/md' import { toast } from 'react-toastify' import { OrganisationMemberInviteType } from '@/apollo/graphql' -import { useSession } from '@/contexts/userContext' +import { useSession, useUser } from '@/contexts/userContext' import { copyRecoveryKit, generateRecoveryPdf } from '@/utils/recovery' import { LogoMark } from '@/components/common/LogoMark' -import { setDevicePassword } from '@/utils/localStorage' +import { setDeviceKey, getDeviceKey, setMemberDeviceKey } from '@/utils/localStorage' import { useRouter } from 'next/navigation' import { decodeb64string, @@ -53,6 +53,7 @@ export default function Invite({ params }: { params: { invite: string } }) { const [acceptInvite] = useMutation(AcceptOrganisationInvite) const { data: session } = useSession() + const { user } = useUser() const router = useRouter() @@ -82,37 +83,41 @@ export default function Invite({ params }: { params: { invite: string } }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.invite]) - const steps: Step[] = [ - { - index: 0, - name: 'Sudo Password', - icon: , - title: 'Set a sudo password', - description: - 'This will be used to encrypt your account keys. You may need to enter this password to perform administrative tasks.', - }, - { - index: 1, - name: 'Account recovery', - icon: , - title: 'Account Recovery', - description: - 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', - }, - ] + // If a deviceKey is cached on this device, skip the sudo-password step — + // the new org's keyring will be wrapped with the cached key directly. + const cachedDeviceKey = user?.userId ? getDeviceKey(user.userId) : null + const skipSudoStep = !!cachedDeviceKey + + const sudoStep: Step = { + index: 0, + name: 'Sudo Password', + icon: , + title: 'Set a sudo password', + description: + 'This will be used to encrypt your account keys. You may need to enter this password to perform administrative tasks.', + } + const recoveryStep: Step = { + index: skipSudoStep ? 0 : 1, + name: 'Account recovery', + icon: , + title: 'Account Recovery', + description: + 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', + } + + const steps: Step[] = skipSudoStep ? [recoveryStep] : [sudoStep, recoveryStep] const computeAccountKeys = () => { return new Promise<{ publicKey: string; encryptedKeyring: string; encryptedMnemonic: string }>( (resolve) => { setTimeout(async () => { const accountSeed = await organisationSeed(mnemonic, invite.organisation.id) - const accountKeyRing = await organisationKeyring(accountSeed) - const deviceKey = await deviceVaultKey(pw, session?.user?.email!) + const deviceKey = + cachedDeviceKey ?? (await deviceVaultKey(pw, session?.user?.email!)) const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey) - const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey) resolve({ @@ -146,8 +151,15 @@ export default function Invite({ params }: { params: { invite: string } }) { setIsLoading(false) if (memberId) { setSuccess(true) - if (savePassword) { - setDevicePassword(memberId, pw) + // Cache the deviceKey. Only applies when the sudo step ran; + // otherwise it was already cached at login time. + if (!skipSudoStep && savePassword && session?.user?.email) { + const deviceKey = await deviceVaultKey(pw, session.user.email) + if (user?.authMethod === 'password' && user?.userId) { + setDeviceKey(user.userId, deviceKey) + } else { + setMemberDeviceKey(memberId, deviceKey) + } } resolve(true) } else { @@ -157,12 +169,13 @@ export default function Invite({ params }: { params: { invite: string } }) { } const validateCurrentStep = () => { - if (step === 0) { + // Sudo password step only exists when not skipped, at index 0. + if (!skipSudoStep && step === 0) { if (pw !== pw2) { errorToast("Passwords don't match") return false } - } else if (step === 1 && !recoveryDownloaded) { + } else if (step === steps.length - 1 && !recoveryDownloaded) { errorToast('Please download the your account recovery kit!') return false } @@ -295,7 +308,7 @@ export default function Invite({ params }: { params: { invite: string } }) {
- {step === 0 && ( + {!skipSudoStep && step === 0 && ( )} - {step === 1 && ( + {step === steps.length - 1 && ( { const licenseActivated = () => licenseData?.license?.isActivated - const ssoSteps: Step[] = [ - { - index: 0, - name: teamNameLock ? 'Organisation setup' : 'Organisation Name', - icon: , - title: teamNameLock ? 'Set up your organisation' : 'Choose a name for your organisation', - description: teamNameLock ? ( - <> - ) : ( -
- Your organisation name can be alphanumeric. - -
[a-zA-Z0-9]
-
-
- ), - }, - { - index: 1, - name: 'Sudo Password', - icon: , - title: 'Set a sudo password', - description: - 'This will be used to encrypt your account keys. You may need to enter this password to unlock your workspace when logging in.', - }, - { - index: 2, - name: 'Account recovery', - icon: , - title: 'Account Recovery', - description: - 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', - }, - ] - - const steps = ssoSteps + // If the user logged in with "remember on this device", we already have + // a deviceKey cached and can wrap this new org's keyring with it directly + // — no need to re-prompt for a sudo password. Falls back to the prompt + // when the cache is empty. + const cachedDeviceKey = user?.userId ? getDeviceKey(user.userId) : null + const skipSudoStep = !!cachedDeviceKey + + const orgStep: Step = { + index: 0, + name: teamNameLock ? 'Organisation setup' : 'Organisation Name', + icon: , + title: teamNameLock ? 'Set up your organisation' : 'Choose a name for your organisation', + description: teamNameLock ? ( + <> + ) : ( +
+ Your organisation name can be alphanumeric. + +
[a-zA-Z0-9]
+
+
+ ), + } + const sudoStep: Step = { + index: 1, + name: 'Sudo Password', + icon: , + title: 'Set a sudo password', + description: + 'This will be used to encrypt your account keys. You may need to enter this password to unlock your workspace when logging in.', + } + const recoveryStep: Step = { + index: skipSudoStep ? 1 : 2, + name: 'Account recovery', + icon: , + title: 'Account Recovery', + description: + 'If you forget your sudo password, you will need to use a recovery kit to regain access to your account.', + } + + const steps: Step[] = skipSudoStep ? [orgStep, recoveryStep] : [orgStep, sudoStep, recoveryStep] const validateCurrentStep = async () => { if (step === 0) { @@ -124,15 +129,14 @@ const Onboard = () => { return true } - // Sudo password step (step 1) - if (step === 1) { + // Sudo password step is at index 1 only when not skipped. + if (!skipSudoStep && step === 1) { if (pw !== pw2) { errorToast("Passwords don't match") return false } } - // Recovery step (last step for both flows) if (step === steps.length - 1) { if (!recoveryDownloaded) { errorToast('Please download the your account recovery kit!') @@ -148,13 +152,14 @@ const Onboard = () => { (resolve) => { setTimeout(async () => { const accountSeed = await organisationSeed(mnemonic, orgId) - const accountKeyRing = await organisationKeyring(accountSeed) - const deviceKey = await deviceVaultKey(pw, session?.user?.email!) + // Use the cached deviceKey when we have one (no sudo prompt was + // shown); otherwise derive from the password the user just set. + const deviceKey = + cachedDeviceKey ?? (await deviceVaultKey(pw, session?.user?.email!)) const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey) - const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey) resolve({ @@ -213,9 +218,17 @@ const Onboard = () => { const newOrg = result.data.createOrganisation.organisation - // Save password if option selected - if (savePassword && newOrg.memberId) { - setDevicePassword(newOrg.memberId, pw) + // Cache the deviceKey for subsequent unlocks. Only meaningful when + // the sudo step ran (otherwise the deviceKey was cached at login). + // Password users key by userId (auth and sudo unified across all + // orgs); SSO users key by memberId (per-org sudo passwords valid). + if (!skipSudoStep && savePassword && session?.user?.email) { + const deviceKey = await deviceVaultKey(pw, session.user.email) + if (user?.authMethod === 'password' && user?.userId) { + setDeviceKey(user.userId, deviceKey) + } else if (newOrg.memberId) { + setMemberDeviceKey(newOrg.memberId, deviceKey) + } } // Create example app with environments @@ -315,7 +328,8 @@ const Onboard = () => { // Determine which content to show for the current step const isRecoveryStep = step === steps.length - 1 - const isSudoPasswordStep = step === 1 + // Sudo password step only renders when not skipped, and is at index 1. + const isSudoPasswordStep = !skipSudoStep && step === 1 return (
diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 0e1cdcc47..bad239164 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -12,7 +12,8 @@ import { FaCheckCircle } from 'react-icons/fa' import Link from 'next/link' import axios from 'axios' import { UrlUtils } from '@/utils/auth' -import { deviceVaultKey, passwordAuthHash } from '@/utils/crypto' +import { passwordAuthHash } from '@/utils/crypto' +import { PasswordStrengthMeter } from '@/components/common/PasswordStrengthMeter' import { ModeToggle } from '@/components/common/ModeToggle' import { FaSun, FaMoon } from 'react-icons/fa6' import { InstanceInfo } from '@/components/InstanceInfo' @@ -28,12 +29,21 @@ const Signup = () => { const [confirmPassword, setConfirmPassword] = useState('') const [loading, setLoading] = useState(false) const [pendingVerification, setPendingVerification] = useState(false) + const [emailLocked, setEmailLocked] = useState(false) + + // When the user arrives from an invite, login forwards both `email` and + // `callbackUrl` (e.g. /invite/). Lock the email field — it must + // match the invitee — and forward callbackUrl through to login on success + // so the invite acceptance flow resumes after authentication. + const callbackUrl = searchParams?.get('callbackUrl') ?? '' useEffect(() => { - // Pre-fill email from query param (from login "Create an account" link) const emailParam = searchParams?.get('email') - if (emailParam) setEmail(emailParam) - }, [searchParams]) + if (emailParam) { + setEmail(emailParam) + if (callbackUrl) setEmailLocked(true) + } + }, [searchParams, callbackUrl]) useEffect(() => { // Authenticated users with orgs should go home, not signup @@ -56,8 +66,7 @@ const Signup = () => { setLoading(true) try { const trimmedEmail = email.toLowerCase().trim() - const masterKey = await deviceVaultKey(password, trimmedEmail) - const authHash = await passwordAuthHash(masterKey) + const authHash = await passwordAuthHash(password, trimmedEmail) const response = await axios.post( UrlUtils.makeUrl( @@ -70,13 +79,17 @@ const Signup = () => { email: trimmedEmail, fullName: fullName.trim(), authHash, + ...(callbackUrl ? { callbackUrl } : {}), }, { withCredentials: true } ) if (response.data.verificationSkipped) { toast.success('Account created! You can now log in.') - router.push('/login') + const loginQs = callbackUrl + ? `?callbackUrl=${encodeURIComponent(callbackUrl)}` + : '' + router.push(`/login${loginQs}`) } else { setPendingVerification(true) } @@ -163,13 +176,13 @@ const Signup = () => {
-
+
Create your account
-
+
{ setValue={setEmail} placeholder="satoshin@gmx.com" required + readOnly={emailLocked} + aria-readonly={emailLocked} /> { required minLength={16} /> + ('email') const [ssoProvider, setSsoProvider] = useState(null) const [ssoMethods, setSsoMethods] = useState([]) @@ -202,16 +204,24 @@ export default function SignInButtons({ setDerivingKey(true) try { - const masterKey = await deviceVaultKey(password, email.toLowerCase().trim()) - const authHash = await passwordAuthHash(masterKey) + const trimmedEmail = email.toLowerCase().trim() + // Derive authHash and deviceKey in parallel — both are independent + // Argon2id derivations from the password. + const [authHash, deviceKey] = await Promise.all([ + passwordAuthHash(password, trimmedEmail), + rememberDevice ? deviceVaultKey(password, trimmedEmail) : Promise.resolve(null), + ]) const response = await axios.post( UrlUtils.makeUrl(process.env.NEXT_PUBLIC_BACKEND_API_BASE!, 'auth', 'password', 'login'), - { email: email.toLowerCase().trim(), authHash }, + { email: trimmedEmail, authHash }, { withCredentials: true } ) if (response.status === 200) { + if (deviceKey && response.data?.userId) { + setDeviceKey(response.data.userId, deviceKey) + } const callbackUrl = searchParams?.get('callbackUrl') window.location.href = callbackUrl?.startsWith('/') ? callbackUrl : '/' } @@ -292,6 +302,17 @@ export default function SignInButtons({ const getProviderName = (id: string) => providerButtons.find((p) => p.id === id)?.name || id + // Build /signup URL forwarding email and callbackUrl when present so the + // invite flow can resume after registration → verification → login. + const signupHref = (() => { + const params = new URLSearchParams() + if (email) params.set('email', email) + const callbackUrl = searchParams?.get('callbackUrl') + if (callbackUrl) params.set('callbackUrl', callbackUrl) + const qs = params.toString() + return qs ? `/signup?${qs}` : '/signup' + })() + return (
@@ -372,7 +393,7 @@ export default function SignInButtons({
Create an account @@ -415,6 +436,16 @@ export default function SignInButtons({
+ + - -
+ <> +
+
Change password
+

+ Update your password for this account. +

+
+ +
+
+ + + !loading && setIsOpen(false)} + > + +
+ + +
+
+ + + +

+ Change password +

+ +
+ +
+ {hasOtherOrgs && ( + + You will need to recover your account keyring in other + organisations when you visit them. + + )} + +
+ +
+ + + +
+ +
+ +

+ Required to re-encrypt your account keyring. +

+ +
+ +
+ + +
+ + + +
+
+
+
+ ) } diff --git a/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql b/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql new file mode 100644 index 000000000..18bafaf71 --- /dev/null +++ b/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql @@ -0,0 +1,19 @@ +mutation ResetAccountPasswordViaRecovery( + $orgId: ID! + $newAuthHash: String! + $identityKey: String! + $wrappedKeyring: String! + $wrappedRecovery: String! +) { + resetAccountPasswordViaRecovery( + orgId: $orgId + newAuthHash: $newAuthHash + identityKey: $identityKey + wrappedKeyring: $wrappedKeyring + wrappedRecovery: $wrappedRecovery + ) { + orgMember { + id + } + } +} diff --git a/frontend/tests/utils/crypto/users.test.ts b/frontend/tests/utils/crypto/users.test.ts index 29457b7a5..679e63d06 100644 --- a/frontend/tests/utils/crypto/users.test.ts +++ b/frontend/tests/utils/crypto/users.test.ts @@ -187,68 +187,65 @@ describe('Password Auth Hash Tests', () => { const password = 'correct-horse-staple-battery' const email = 'satoshi@gmx.com' - test('passwordAuthHash produces consistent output for same masterKey', async () => { - const { deviceVaultKey, passwordAuthHash } = await import('@/utils/crypto') - const masterKey = await deviceVaultKey(password, email) - const hash1 = await passwordAuthHash(masterKey) - const hash2 = await passwordAuthHash(masterKey) + test('passwordAuthHash is deterministic for the same (password, email)', async () => { + const { passwordAuthHash } = await import('@/utils/crypto') + const hash1 = await passwordAuthHash(password, email) + const hash2 = await passwordAuthHash(password, email) expect(hash1).toBe(hash2) }) test('passwordAuthHash output is 64-char hex string (32 bytes)', async () => { - const { deviceVaultKey, passwordAuthHash } = await import('@/utils/crypto') - const masterKey = await deviceVaultKey(password, email) - const hash = await passwordAuthHash(masterKey) + const { passwordAuthHash } = await import('@/utils/crypto') + const hash = await passwordAuthHash(password, email) expect(hash).toMatch(/^[a-f0-9]{64}$/) }) - test('passwordAuthHash differs from masterKey', async () => { - const { deviceVaultKey, passwordAuthHash } = await import('@/utils/crypto') - const masterKey = await deviceVaultKey(password, email) - const hash = await passwordAuthHash(masterKey) - expect(hash).not.toBe(masterKey) + test('different passwords produce different authHashes', async () => { + const { passwordAuthHash } = await import('@/utils/crypto') + const hash1 = await passwordAuthHash(password, email) + const hash2 = await passwordAuthHash('different-password-here!!', email) + expect(hash1).not.toBe(hash2) }) - test('different masterKeys produce different authHashes', async () => { - const { deviceVaultKey, passwordAuthHash } = await import('@/utils/crypto') - const masterKey1 = await deviceVaultKey(password, email) - const masterKey2 = await deviceVaultKey('different-password-here!!', email) - const hash1 = await passwordAuthHash(masterKey1) - const hash2 = await passwordAuthHash(masterKey2) + test('different emails produce different authHashes for the same password', async () => { + // Salt domain-separation by email; two users with the same password + // must not produce identical authHashes. + const { passwordAuthHash } = await import('@/utils/crypto') + const hash1 = await passwordAuthHash(password, email) + const hash2 = await passwordAuthHash(password, 'someone-else@example.com') expect(hash1).not.toBe(hash2) }) - test('authHash cannot reverse to masterKey (one-way)', async () => { - // This is a property test: authHash is a BLAKE2b hash of masterKey, - // so the authHash should not contain the masterKey as a substring + test('authHash is independent of deviceKey (parallel, not chained)', async () => { + // Both come from the same (password, email) input but use different + // Argon2id parameter tiers (INTERACTIVE vs MODERATE). Different + // memory/iteration parameters produce independent KDF outputs, so + // knowing one cannot let you derive the other without the password. const { deviceVaultKey, passwordAuthHash } = await import('@/utils/crypto') - const masterKey = await deviceVaultKey(password, email) - const hash = await passwordAuthHash(masterKey) - expect(hash).not.toBe(masterKey) - expect(masterKey).not.toContain(hash) - expect(hash).not.toContain(masterKey) + const deviceKey = await deviceVaultKey(password, email) + const authHash = await passwordAuthHash(password, email) + expect(authHash).not.toBe(deviceKey) + expect(authHash).not.toContain(deviceKey) + expect(deviceKey).not.toContain(authHash) }) - test('full double-derivation: password → masterKey → authHash', async () => { - // Verifies the complete protocol works end-to-end + test('end-to-end: deviceKey wraps keyring, authHash is sent to server', async () => { const { deviceVaultKey, passwordAuthHash, encryptAccountKeyring, decryptAccountKeyring } = await import('@/utils/crypto') - const masterKey = await deviceVaultKey(password, email) - const authHash = await passwordAuthHash(masterKey) + const deviceKey = await deviceVaultKey(password, email) + const authHash = await passwordAuthHash(password, email) - // masterKey encrypts keyring (client-side) const keyring = { symmetricKey: 'a'.repeat(64), privateKey: 'b'.repeat(128), publicKey: 'c'.repeat(64), } - const encrypted = await encryptAccountKeyring(keyring, masterKey) - const decrypted = await decryptAccountKeyring(encrypted, masterKey) + const encrypted = await encryptAccountKeyring(keyring, deviceKey) + const decrypted = await decryptAccountKeyring(encrypted, deviceKey) expect(decrypted).toEqual(keyring) - // authHash is what goes to the server (different from masterKey) - expect(authHash).not.toBe(masterKey) + expect(authHash).not.toBe(deviceKey) expect(authHash).toMatch(/^[a-f0-9]{64}$/) }) }) diff --git a/frontend/utils/crypto/users.ts b/frontend/utils/crypto/users.ts index c0feb9eec..07d1b3664 100644 --- a/frontend/utils/crypto/users.ts +++ b/frontend/utils/crypto/users.ts @@ -87,25 +87,32 @@ export const deviceVaultKey = async (password: string, email: string): Promise} - hex-encoded auth hash to send to server + * Parallel to deviceVaultKey rather than chained, so a cached deviceKey + * in localStorage cannot be used to compute authHash. The two outputs + * are independent because they use different Argon2id parameter tiers + * (INTERACTIVE vs MODERATE) — same password and salt, different KDF + * settings produce independent keys. */ -export const passwordAuthHash = async (masterKey: string): Promise => { +export const passwordAuthHash = async ( + password: string, + email: string +): Promise => { await _sodium.ready const sodium = _sodium - const hash = sodium.crypto_generichash( - 32, - sodium.from_hex(masterKey), - sodium.from_string('phaseAuth') - ) + // INTERACTIVE: ~64MiB / ~100ms — much lighter than deviceVaultKey's + // MODERATE tier, but still memory-hard so a wire intercept of authHash + // can't be trivially ground back to the password. + const OPSLIMIT = sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE + const MEMLIMIT = sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE + const ALG = sodium.crypto_pwhash_ALG_ARGON2ID13 + + const salt = await saltFromString(email) + + const hash = sodium.crypto_pwhash(32, password, salt, OPSLIMIT, MEMLIMIT, ALG) return sodium.to_hex(hash) } diff --git a/frontend/utils/localStorage.ts b/frontend/utils/localStorage.ts index 48222e05a..7d3a1433f 100644 --- a/frontend/utils/localStorage.ts +++ b/frontend/utils/localStorage.ts @@ -145,3 +145,89 @@ export const deleteDevicePassword = (memberId: string): boolean => { return false // Return false if the memberId was not found } + +// Two storage shapes for the derived deviceKey, both hex-encoded 32 bytes: +// +// phaseDeviceKeys — keyed by userId. Used by password users. +// One key unlocks every org because the auth and +// sudo passwords are kept identical (enforced by +// password_reset_via_recovery). +// +// phaseMemberDeviceKeys — keyed by memberId. Used by SSO users. +// Each org membership can have a distinct sudo +// password set during onboard/invite, so storage +// is per-membership. +// +// Both store one-way Argon2id derivations, never the raw password. + +interface DeviceKeyEntry { + userId: string + deviceKey: string +} + +interface MemberDeviceKeyEntry { + memberId: string + deviceKey: string +} + +export const setDeviceKey = (userId: string, deviceKey: string): void => { + const existing = localStorage.getItem('phaseDeviceKeys') + const entries: DeviceKeyEntry[] = existing ? JSON.parse(existing) : [] + const idx = entries.findIndex((e) => e.userId === userId) + if (idx !== -1) { + entries[idx].deviceKey = deviceKey + } else { + entries.push({ userId, deviceKey }) + } + localStorage.setItem('phaseDeviceKeys', JSON.stringify(entries)) +} + +export const getDeviceKey = (userId: string): string | null => { + const stored = localStorage.getItem('phaseDeviceKeys') + if (!stored) return null + const entries: DeviceKeyEntry[] = JSON.parse(stored) + const entry = entries.find((e) => e.userId === userId) + return entry?.deviceKey ?? null +} + +export const deleteDeviceKey = (userId: string): boolean => { + const stored = localStorage.getItem('phaseDeviceKeys') + if (!stored) return false + const entries: DeviceKeyEntry[] = JSON.parse(stored) + const idx = entries.findIndex((e) => e.userId === userId) + if (idx === -1) return false + entries.splice(idx, 1) + localStorage.setItem('phaseDeviceKeys', JSON.stringify(entries)) + return true +} + +export const setMemberDeviceKey = (memberId: string, deviceKey: string): void => { + const existing = localStorage.getItem('phaseMemberDeviceKeys') + const entries: MemberDeviceKeyEntry[] = existing ? JSON.parse(existing) : [] + const idx = entries.findIndex((e) => e.memberId === memberId) + if (idx !== -1) { + entries[idx].deviceKey = deviceKey + } else { + entries.push({ memberId, deviceKey }) + } + localStorage.setItem('phaseMemberDeviceKeys', JSON.stringify(entries)) +} + +export const getMemberDeviceKey = (memberId: string): string | null => { + const stored = localStorage.getItem('phaseMemberDeviceKeys') + if (!stored) return null + const entries: MemberDeviceKeyEntry[] = JSON.parse(stored) + const entry = entries.find((e) => e.memberId === memberId) + return entry?.deviceKey ?? null +} + +export const deleteMemberDeviceKey = (memberId: string): boolean => { + const stored = localStorage.getItem('phaseMemberDeviceKeys') + if (!stored) return false + const entries: MemberDeviceKeyEntry[] = JSON.parse(stored) + const idx = entries.findIndex((e) => e.memberId === memberId) + if (idx === -1) return false + entries.splice(idx, 1) + localStorage.setItem('phaseMemberDeviceKeys', JSON.stringify(entries)) + return true +} From 5fcf87a01b640afb9ca66ad966a12eb60c06aada Mon Sep 17 00:00:00 2001 From: rohan Date: Sat, 25 Apr 2026 16:54:43 +0530 Subject: [PATCH 067/100] fix(auth): require identity proof on keyring rewrap; reject invite signup with mismatched email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings from the unified-password design: 1. UpdateUserWrappedSecretsMutation (used by SSO recovery) now requires identity_key matching the org's stored value. Without this proof an authenticated session could overwrite its own wrapped_keyring with arbitrary garbage, locking the user out. Recovery page derives the value from the user's mnemonic input; the legacy-migration callsite echoes the server's value (the mutation's check is a no-op there since there's no decryption context to derive from, but the security relevant path keeps its proof). 2. password_register rejects with 403 when callbackUrl=/invite/ and the submitted email doesn't match the invitee. The frontend locks the field but a tampered request would otherwise register with an arbitrary email and fail later at acceptance with no clear breadcrumb. 3. _safe_internal_path is now a strict allowlist for /invite/ only. Previously any internal path was accepted, which would let a crafted verification URL redirect through arbitrary routes. Also fixes the docstring on passwordAuthHash to accurately describe the salt usage (same salt as deviceVaultKey, parameter-tier divergence for independence) — earlier wording overstated the domain-separation guarantee. --- backend/api/views/auth_password.py | 46 ++++++++-- .../graphene/mutations/organisation.py | 32 ++++++- backend/tests/test_auth_password.py | 91 +++++++++++++++++++ frontend/apollo/gql.ts | 12 ++- frontend/apollo/graphql.ts | 87 +++++++++++++++++- frontend/apollo/schema.graphql | 65 ++++++++++++- frontend/app/[team]/recovery/page.tsx | 1 + frontend/contexts/organisationContext.tsx | 8 ++ .../auth/resetAccountPasswordViaRecovery.gql | 2 +- .../organisation/updateUserWrappedSecrets.gql | 14 ++- frontend/utils/crypto/users.ts | 15 ++- 11 files changed, 351 insertions(+), 22 deletions(-) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index b4e267ed2..ff5f3a8cc 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -1,6 +1,7 @@ import json import logging import os +import re import secrets from datetime import timedelta @@ -84,15 +85,21 @@ def _smtp_configured(): return bool(getattr(settings, "EMAIL_HOST", "")) +_INVITE_PATH_RE = re.compile(r"^/invite/[A-Za-z0-9+/=_-]+/?$") + + def _safe_internal_path(value): - """Return value if it's a safe same-origin path (e.g. '/invite/abc'), - else None. Rejects schemes, protocol-relative URLs, and absolute URLs - so a verification link can't be tricked into redirecting off-site.""" + """Return value if it's a safe same-origin invite path (e.g. + '/invite/'), else None. + + Strictly allowlisted to the invite acceptance flow — that's the only + legitimate destination we forward through email verification today. + Allowing arbitrary internal paths would let a crafted verification + URL redirect through any internal route (e.g. '/login?error=...' + with injected query params).""" if not isinstance(value, str) or not value: return None - if not value.startswith("/") or value.startswith("//"): - return None - if "://" in value: + if not _INVITE_PATH_RE.match(value): return None return value @@ -154,6 +161,33 @@ def password_register(request): if User.objects.filter(email=email).exists(): return JsonResponse({"error": "An account with this email already exists."}, status=409) + # If signup was triggered from an invite link, the registered email must + # match the invitee email. The frontend forwards callbackUrl=/invite/ + # for invite-driven signups and locks the email field; this enforces the + # same constraint server-side so a tampered request can't register an + # arbitrary email and then fail downstream at invite acceptance. + callback_url = data.get("callbackUrl") or "" + if callback_url.startswith("/invite/"): + from base64 import b64decode + try: + encoded_invite = callback_url[len("/invite/"):].split("/")[0].split("?")[0] + invite_id = b64decode(encoded_invite).decode("utf-8") + invite = OrganisationMemberInvite.objects.get( + id=invite_id, + valid=True, + expires_at__gt=timezone.now(), + ) + if invite.invitee_email.lower().strip() != email: + return JsonResponse( + {"error": "This invite is for a different email address."}, + status=403, + ) + except (OrganisationMemberInvite.DoesNotExist, ValueError, Exception): + # Invalid invite reference — ignore and let registration proceed + # under the user's submitted email. Acceptance will fail later + # with a clearer error if the invite truly is bad. + pass + # Skip verification if explicitly configured OR if SMTP isn't set up # (no point creating inactive accounts when emails can't be delivered) skip_verification = _skip_email_verification() or not _smtp_configured() diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py index ed8537254..03196999c 100644 --- a/backend/backend/graphene/mutations/organisation.py +++ b/backend/backend/graphene/mutations/organisation.py @@ -92,18 +92,42 @@ def mutate( class UpdateUserWrappedSecretsMutation(graphene.Mutation): + """Re-wrap THIS org's keyring after the caller proves they hold the + recovery mnemonic. Used by SSO recovery (where there's no login + password to verify against, so identity is proven via the mnemonic + alone). + + Requires identity_key matching the org's stored identity_key — proves + the caller derived the keyring from the right mnemonic. Without this + proof, an authenticated user (or session-cookie holder) could + overwrite their own wrapped_keyring with arbitrary garbage and lock + themselves out of the org permanently. + """ + class Arguments: org_id = graphene.ID(required=True) + identity_key = graphene.String(required=True) wrapped_keyring = graphene.String(required=True) wrapped_recovery = graphene.String(required=True) org_member = graphene.Field(OrganisationMemberType) @classmethod - def mutate(cls, root, info, org_id, wrapped_keyring, wrapped_recovery): - org_member = OrganisationMember.objects.get( - organisation_id=org_id, user=info.context.user, deleted_at=None - ) + def mutate(cls, root, info, org_id, identity_key, wrapped_keyring, wrapped_recovery): + try: + org = Organisation.objects.get(id=org_id) + except Organisation.DoesNotExist: + raise GraphQLError("Organisation not found.") + + if org.identity_key != identity_key: + raise GraphQLError("Invalid recovery proof.") + + try: + org_member = OrganisationMember.objects.get( + organisation=org, user=info.context.user, deleted_at=None + ) + except OrganisationMember.DoesNotExist: + raise GraphQLError("Not a member of this organisation.") org_member.wrapped_keyring = wrapped_keyring org_member.wrapped_recovery = wrapped_recovery diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py index 24f058f0f..e8a39c101 100644 --- a/backend/tests/test_auth_password.py +++ b/backend/tests/test_auth_password.py @@ -129,6 +129,37 @@ def test_register_rejects_invalid_email(self): self.assertEqual(response.status_code, 400) + @patch("api.views.auth_password.OrganisationMemberInvite") + @patch("api.views.auth_password.get_user_model") + def test_register_rejects_invite_email_mismatch( + self, mock_get_user, mock_invite_cls + ): + """Invite-driven signup must use the invitee's email. The frontend + locks the field but a tampered request with a different email + must still be rejected server-side.""" + from base64 import b64encode + + User = MagicMock() + User.objects.filter.return_value.exists.return_value = False + mock_get_user.return_value = User + + invite = MagicMock() + invite.invitee_email = "invitee@example.com" + mock_invite_cls.objects.get.return_value = invite + + invite_id = "abc-invite-id" + encoded = b64encode(invite_id.encode()).decode() + payload = dict( + self.VALID_PAYLOAD, + email="attacker@example.com", + callbackUrl=f"/invite/{encoded}", + ) + request = _make_post("/auth/password/register/", payload) + response = password_register(request) + + self.assertEqual(response.status_code, 403) + User.objects.create_user.assert_not_called() + # --------------------------------------------------------------------------- # verify_email @@ -1053,6 +1084,66 @@ def test_sso_user_recovery_rejected(self): ) user.set_password.assert_not_called() + @patch("backend.graphene.mutations.organisation.OrganisationMember") + @patch("backend.graphene.mutations.organisation.Organisation") + def test_sso_recovery_rewrap_requires_identity_proof(self, mock_org, mock_om): + """SSO recovery via UpdateUserWrappedSecretsMutation must reject + when supplied identity_key doesn't match — without this proof an + authenticated user (or session-cookie holder) could overwrite + their wrapped_keyring with arbitrary garbage.""" + from graphql import GraphQLError + from backend.graphene.mutations.organisation import ( + UpdateUserWrappedSecretsMutation, + ) + user = MagicMock() + + org = MagicMock() + org.identity_key = "real_key" + mock_org.objects.get.return_value = org + + with self.assertRaises(GraphQLError): + UpdateUserWrappedSecretsMutation.mutate( + None, + self._info(user), + org_id="org-1", + identity_key="wrong_key", + wrapped_keyring="garbage", + wrapped_recovery="garbage", + ) + mock_om.objects.get.assert_not_called() + + @patch("backend.graphene.mutations.organisation.OrganisationMember") + @patch("backend.graphene.mutations.organisation.Organisation") + def test_sso_recovery_rewrap_succeeds_with_valid_identity( + self, mock_org, mock_om + ): + """Matching identity_key allows the keyring rewrap.""" + from backend.graphene.mutations.organisation import ( + UpdateUserWrappedSecretsMutation, + ) + user = MagicMock() + + org = MagicMock() + org.identity_key = "matching_key" + mock_org.objects.get.return_value = org + + org_member = MagicMock() + mock_om.objects.get.return_value = org_member + + result = UpdateUserWrappedSecretsMutation.mutate( + None, + self._info(user), + org_id="org-1", + identity_key="matching_key", + wrapped_keyring="new_wk", + wrapped_recovery="new_wr", + ) + + self.assertEqual(org_member.wrapped_keyring, "new_wk") + self.assertEqual(org_member.wrapped_recovery, "new_wr") + org_member.save.assert_called_once() + self.assertIs(result.org_member, org_member) + class CrossAuthMethodTest(_ThrottleClearMixin, unittest.TestCase): """Tests for cross-auth-method edge cases.""" diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 1039c1a61..1e94b1da4 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -26,6 +26,7 @@ type Documents = { "mutation RemoveMemberFromApp($memberId: ID!, $memberType: MemberType, $appId: ID!) {\n removeAppMember(memberId: $memberId, memberType: $memberType, appId: $appId) {\n app {\n id\n }\n }\n}": typeof types.RemoveMemberFromAppDocument, "mutation UpdateAppInfoOp($id: ID!, $name: String, $description: String) {\n updateAppInfo(id: $id, name: $name, description: $description) {\n app {\n id\n name\n description\n }\n }\n}": typeof types.UpdateAppInfoOpDocument, "mutation UpdateEnvScope($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": typeof types.UpdateEnvScopeDocument, + "mutation ResetPasswordViaRecovery($orgId: ID!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n resetAccountPasswordViaRecovery(\n orgId: $orgId\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.ResetPasswordViaRecoveryDocument, "mutation CancelStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n cancelSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n }\n}": typeof types.CancelStripeSubscriptionDocument, "mutation CreateStripeSetupIntentOp($organisationId: ID!) {\n createSetupIntent(organisationId: $organisationId) {\n clientSecret\n }\n}": typeof types.CreateStripeSetupIntentOpDocument, "mutation DeleteStripePaymentMethod($organisationId: ID!, $paymentMethodId: String!) {\n deletePaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": typeof types.DeleteStripePaymentMethodDocument, @@ -72,7 +73,7 @@ type Documents = { "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": typeof types.RemoveMemberDocument, "mutation TransferOrgOwnership($organisationId: ID!, $newOwnerId: ID!, $billingEmail: String) {\n transferOrganisationOwnership(\n organisationId: $organisationId\n newOwnerId: $newOwnerId\n billingEmail: $billingEmail\n ) {\n ok\n }\n}": typeof types.TransferOrgOwnershipDocument, "mutation UpdateMemberRole($memberId: ID!, $roleId: ID!) {\n updateOrganisationMemberRole(memberId: $memberId, roleId: $roleId) {\n orgMember {\n id\n role {\n name\n }\n }\n }\n}": typeof types.UpdateMemberRoleDocument, - "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.UpdateWrappedSecretsDocument, + "mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.UpdateWrappedSecretsDocument, "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": typeof types.RotateAppKeyDocument, "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}": typeof types.CreateServiceAccountOpDocument, "mutation CreateSAToken($serviceAccountId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n token {\n id\n }\n }\n}": typeof types.CreateSaTokenDocument, @@ -193,6 +194,7 @@ const documents: Documents = { "mutation RemoveMemberFromApp($memberId: ID!, $memberType: MemberType, $appId: ID!) {\n removeAppMember(memberId: $memberId, memberType: $memberType, appId: $appId) {\n app {\n id\n }\n }\n}": types.RemoveMemberFromAppDocument, "mutation UpdateAppInfoOp($id: ID!, $name: String, $description: String) {\n updateAppInfo(id: $id, name: $name, description: $description) {\n app {\n id\n name\n description\n }\n }\n}": types.UpdateAppInfoOpDocument, "mutation UpdateEnvScope($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}": types.UpdateEnvScopeDocument, + "mutation ResetPasswordViaRecovery($orgId: ID!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n resetAccountPasswordViaRecovery(\n orgId: $orgId\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.ResetPasswordViaRecoveryDocument, "mutation CancelStripeSubscription($organisationId: ID!, $subscriptionId: String!) {\n cancelSubscription(\n organisationId: $organisationId\n subscriptionId: $subscriptionId\n ) {\n success\n }\n}": types.CancelStripeSubscriptionDocument, "mutation CreateStripeSetupIntentOp($organisationId: ID!) {\n createSetupIntent(organisationId: $organisationId) {\n clientSecret\n }\n}": types.CreateStripeSetupIntentOpDocument, "mutation DeleteStripePaymentMethod($organisationId: ID!, $paymentMethodId: String!) {\n deletePaymentMethod(\n organisationId: $organisationId\n paymentMethodId: $paymentMethodId\n ) {\n ok\n }\n}": types.DeleteStripePaymentMethodDocument, @@ -239,7 +241,7 @@ const documents: Documents = { "mutation RemoveMember($memberId: ID!) {\n deleteOrganisationMember(memberId: $memberId) {\n ok\n }\n}": types.RemoveMemberDocument, "mutation TransferOrgOwnership($organisationId: ID!, $newOwnerId: ID!, $billingEmail: String) {\n transferOrganisationOwnership(\n organisationId: $organisationId\n newOwnerId: $newOwnerId\n billingEmail: $billingEmail\n ) {\n ok\n }\n}": types.TransferOrgOwnershipDocument, "mutation UpdateMemberRole($memberId: ID!, $roleId: ID!) {\n updateOrganisationMemberRole(memberId: $memberId, roleId: $roleId) {\n orgMember {\n id\n role {\n name\n }\n }\n }\n}": types.UpdateMemberRoleDocument, - "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.UpdateWrappedSecretsDocument, + "mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.UpdateWrappedSecretsDocument, "mutation RotateAppKey($id: ID!, $appToken: String!, $wrappedKeyShare: String!) {\n rotateAppKeys(id: $id, appToken: $appToken, wrappedKeyShare: $wrappedKeyShare) {\n app {\n id\n }\n }\n}": types.RotateAppKeyDocument, "mutation CreateServiceAccountOp($name: String!, $orgId: ID!, $roleId: ID!, $identityKey: String!, $handlers: [ServiceAccountHandlerInput], $serverWrappedKeyring: String, $serverWrappedRecovery: String) {\n createServiceAccount(\n name: $name\n organisationId: $orgId\n roleId: $roleId\n identityKey: $identityKey\n handlers: $handlers\n serverWrappedKeyring: $serverWrappedKeyring\n serverWrappedRecovery: $serverWrappedRecovery\n ) {\n serviceAccount {\n id\n }\n }\n}": types.CreateServiceAccountOpDocument, "mutation CreateSAToken($serviceAccountId: ID!, $name: String!, $identityKey: String!, $token: String!, $wrappedKeyShare: String!, $expiry: BigInt) {\n createServiceAccountToken(\n serviceAccountId: $serviceAccountId\n name: $name\n identityKey: $identityKey\n token: $token\n wrappedKeyShare: $wrappedKeyShare\n expiry: $expiry\n ) {\n token {\n id\n }\n }\n}": types.CreateSaTokenDocument, @@ -410,6 +412,10 @@ export function graphql(source: "mutation UpdateAppInfoOp($id: ID!, $name: Strin * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "mutation UpdateEnvScope($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateEnvScope($memberId: ID!, $memberType: MemberType, $appId: ID!, $envKeys: [EnvironmentKeyInput]) {\n updateMemberEnvironmentScope(\n memberId: $memberId\n memberType: $memberType\n appId: $appId\n envKeys: $envKeys\n ) {\n app {\n id\n }\n }\n}"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "mutation ResetPasswordViaRecovery($orgId: ID!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n resetAccountPasswordViaRecovery(\n orgId: $orgId\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation ResetPasswordViaRecovery($orgId: ID!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n resetAccountPasswordViaRecovery(\n orgId: $orgId\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -597,7 +603,7 @@ export function graphql(source: "mutation UpdateMemberRole($memberId: ID!, $role /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"]; +export function graphql(source: "mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation UpdateWrappedSecrets($orgId: ID!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n updateMemberWrappedSecrets(\n orgId: $orgId\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index 53ed031cc..4fdb92f0d 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -1047,6 +1047,23 @@ export type Mutation = { removeOverride?: Maybe; renameEnvironment?: Maybe; renewDynamicSecretLease?: Maybe; + /** + * Re-wrap THIS org's keyring after verifying identity via the + * recovery mnemonic. The supplied password MUST match the user's + * current login auth — auth and sudo passwords stay unified, period. + * + * Two server-side proofs are required: + * 1. identity_key matches the org's stored identity_key — proves the + * caller derived the keyring from the right mnemonic. + * 2. new_auth_hash matches user.password — proves the password the + * user is wrapping the keyring with is also their account login + * auth. + * + * A failed (2) means the user is trying to set a different password + * for this org's keyring than what authenticates them. We never + * persist that — it would split auth and sudo apart. + */ + resetAccountPasswordViaRecovery?: Maybe; resumeSubscription?: Maybe; revokeDynamicSecretLease?: Maybe; rotateAppKeys?: Maybe; @@ -1066,6 +1083,18 @@ export type Mutation = { updateEnvironmentOrder?: Maybe; updateIdentity?: Maybe; updateMemberEnvironmentScope?: Maybe; + /** + * Re-wrap THIS org's keyring after the caller proves they hold the + * recovery mnemonic. Used by SSO recovery (where there's no login + * password to verify against, so identity is proven via the mnemonic + * alone). + * + * Requires identity_key matching the org's stored identity_key — proves + * the caller derived the keyring from the right mnemonic. Without this + * proof, an authenticated user (or session-cookie holder) could + * overwrite their own wrapped_keyring with arbitrary garbage and lock + * themselves out of the org permanently. + */ updateMemberWrappedSecrets?: Maybe; updateNetworkAccessPolicy?: Maybe; updateOrganisationMemberRole?: Maybe; @@ -1609,6 +1638,15 @@ export type MutationRenewDynamicSecretLeaseArgs = { }; +export type MutationResetAccountPasswordViaRecoveryArgs = { + identityKey: Scalars['String']['input']; + newAuthHash: Scalars['String']['input']; + orgId: Scalars['ID']['input']; + wrappedKeyring: Scalars['String']['input']; + wrappedRecovery: Scalars['String']['input']; +}; + + export type MutationResumeSubscriptionArgs = { organisationId?: InputMaybe; subscriptionId: Scalars['String']['input']; @@ -1721,6 +1759,7 @@ export type MutationUpdateMemberEnvironmentScopeArgs = { export type MutationUpdateMemberWrappedSecretsArgs = { + identityKey: Scalars['String']['input']; orgId: Scalars['ID']['input']; wrappedKeyring: Scalars['String']['input']; wrappedRecovery: Scalars['String']['input']; @@ -2394,6 +2433,27 @@ export type RenewLeaseMutation = { lease?: Maybe; }; +/** + * Re-wrap THIS org's keyring after verifying identity via the + * recovery mnemonic. The supplied password MUST match the user's + * current login auth — auth and sudo passwords stay unified, period. + * + * Two server-side proofs are required: + * 1. identity_key matches the org's stored identity_key — proves the + * caller derived the keyring from the right mnemonic. + * 2. new_auth_hash matches user.password — proves the password the + * user is wrapping the keyring with is also their account login + * auth. + * + * A failed (2) means the user is trying to set a different password + * for this org's keyring than what authenticates them. We never + * persist that — it would split auth and sudo apart. + */ +export type ResetAccountPasswordViaRecoveryMutation = { + __typename?: 'ResetAccountPasswordViaRecoveryMutation'; + orgMember?: Maybe; +}; + export type RevokeLeaseMutation = { __typename?: 'RevokeLeaseMutation'; lease?: Maybe; @@ -2749,6 +2809,18 @@ export type UpdateSyncAuthentication = { sync?: Maybe; }; +/** + * Re-wrap THIS org's keyring after the caller proves they hold the + * recovery mnemonic. Used by SSO recovery (where there's no login + * password to verify against, so identity is proven via the mnemonic + * alone). + * + * Requires identity_key matching the org's stored identity_key — proves + * the caller derived the keyring from the right mnemonic. Without this + * proof, an authenticated user (or session-cookie holder) could + * overwrite their own wrapped_keyring with arbitrary garbage and lock + * themselves out of the org permanently. + */ export type UpdateUserWrappedSecretsMutation = { __typename?: 'UpdateUserWrappedSecretsMutation'; orgMember?: Maybe; @@ -2897,6 +2969,17 @@ export type UpdateEnvScopeMutationVariables = Exact<{ export type UpdateEnvScopeMutation = { __typename?: 'Mutation', updateMemberEnvironmentScope?: { __typename?: 'UpdateMemberEnvScopeMutation', app?: { __typename?: 'AppType', id: string } | null } | null }; +export type ResetPasswordViaRecoveryMutationVariables = Exact<{ + orgId: Scalars['ID']['input']; + newAuthHash: Scalars['String']['input']; + identityKey: Scalars['String']['input']; + wrappedKeyring: Scalars['String']['input']; + wrappedRecovery: Scalars['String']['input']; +}>; + + +export type ResetPasswordViaRecoveryMutation = { __typename?: 'Mutation', resetAccountPasswordViaRecovery?: { __typename?: 'ResetAccountPasswordViaRecoveryMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null } | null }; + export type CancelStripeSubscriptionMutationVariables = Exact<{ organisationId: Scalars['ID']['input']; subscriptionId: Scalars['String']['input']; @@ -3328,6 +3411,7 @@ export type UpdateMemberRoleMutation = { __typename?: 'Mutation', updateOrganisa export type UpdateWrappedSecretsMutationVariables = Exact<{ orgId: Scalars['ID']['input']; + identityKey: Scalars['String']['input']; wrappedKeyring: Scalars['String']['input']; wrappedRecovery: Scalars['String']['input']; }>; @@ -4212,6 +4296,7 @@ export const BulkAddMembersToAppDocument = {"kind":"Document","definitions":[{"k export const RemoveMemberFromAppDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMemberFromApp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MemberType"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeAppMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateAppInfoOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAppInfoOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"description"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAppInfo"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"description"},"value":{"kind":"Variable","name":{"kind":"Name","value":"description"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateEnvScopeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateEnvScope"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"MemberType"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EnvironmentKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberEnvironmentScope"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"memberType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberType"}}},{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}},{"kind":"Argument","name":{"kind":"Name","value":"envKeys"},"value":{"kind":"Variable","name":{"kind":"Name","value":"envKeys"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ResetPasswordViaRecoveryDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResetPasswordViaRecovery"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newAuthHash"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resetAccountPasswordViaRecovery"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"newAuthHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newAuthHash"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CancelStripeSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelStripeSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"subscriptionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelSubscription"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"subscriptionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"subscriptionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const CreateStripeSetupIntentOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateStripeSetupIntentOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createSetupIntent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"clientSecret"}}]}}]}}]} as unknown as DocumentNode; export const DeleteStripePaymentMethodDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteStripePaymentMethod"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePaymentMethod"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"paymentMethodId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"paymentMethodId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; @@ -4258,7 +4343,7 @@ export const DeleteOrgInviteDocument = {"kind":"Document","definitions":[{"kind" export const RemoveMemberDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveMember"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOrganisationMember"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const TransferOrgOwnershipDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TransferOrgOwnership"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingEmail"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transferOrganisationOwnership"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}},{"kind":"Argument","name":{"kind":"Name","value":"newOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newOwnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"billingEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingEmail"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}}]}}]}}]} as unknown as DocumentNode; export const UpdateMemberRoleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMemberRole"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrganisationMemberRole"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"memberId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"memberId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]} as unknown as DocumentNode; -export const UpdateWrappedSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWrappedSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberWrappedSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; +export const UpdateWrappedSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWrappedSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMemberWrappedSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orgMember"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const RotateAppKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RotateAppKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rotateAppKeys"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"appToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateServiceAccountOpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateServiceAccountOp"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}},"type":{"kind":"ListType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServiceAccountHandlerInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"roleId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roleId"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"handlers"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handlers"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedKeyring"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedKeyring"}}},{"kind":"Argument","name":{"kind":"Name","value":"serverWrappedRecovery"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serverWrappedRecovery"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serviceAccount"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const CreateSaTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateSAToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BigInt"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createServiceAccountToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"serviceAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"serviceAccountId"}}},{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"identityKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"identityKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}},{"kind":"Argument","name":{"kind":"Name","value":"wrappedKeyShare"},"value":{"kind":"Variable","name":{"kind":"Name","value":"wrappedKeyShare"}}},{"kind":"Argument","name":{"kind":"Name","value":"expiry"},"value":{"kind":"Variable","name":{"kind":"Name","value":"expiry"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 75ce7c565..8c6cf285e 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -1048,7 +1048,38 @@ type Mutation { The new owner must have global access (Admin role) to ensure they have all necessary keys. """ transferOrganisationOwnership(billingEmail: String, newOwnerId: ID!, organisationId: ID!): TransferOrganisationOwnershipMutation - updateMemberWrappedSecrets(orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): UpdateUserWrappedSecretsMutation + + """ + Re-wrap THIS org's keyring after the caller proves they hold the + recovery mnemonic. Used by SSO recovery (where there's no login + password to verify against, so identity is proven via the mnemonic + alone). + + Requires identity_key matching the org's stored identity_key — proves + the caller derived the keyring from the right mnemonic. Without this + proof, an authenticated user (or session-cookie holder) could + overwrite their own wrapped_keyring with arbitrary garbage and lock + themselves out of the org permanently. + """ + updateMemberWrappedSecrets(identityKey: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): UpdateUserWrappedSecretsMutation + + """ + Re-wrap THIS org's keyring after verifying identity via the + recovery mnemonic. The supplied password MUST match the user's + current login auth — auth and sudo passwords stay unified, period. + + Two server-side proofs are required: + 1. identity_key matches the org's stored identity_key — proves the + caller derived the keyring from the right mnemonic. + 2. new_auth_hash matches user.password — proves the password the + user is wrapping the keyring with is also their account login + auth. + + A failed (2) means the user is trying to set a different password + for this org's keyring than what authenticates them. We never + persist that — it would split auth and sudo apart. + """ + resetAccountPasswordViaRecovery(identityKey: String!, newAuthHash: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): ResetAccountPasswordViaRecoveryMutation deleteInvitation(inviteId: ID!): DeleteInviteMutation createApp(appSeed: String!, appToken: String!, appVersion: Int!, id: ID!, identityKey: String!, name: String!, organisationId: ID!, wrappedKeyShare: String!): CreateAppMutation rotateAppKeys(appToken: String!, id: ID!, wrappedKeyShare: String!): RotateAppKeysMutation @@ -1179,10 +1210,42 @@ type TransferOrganisationOwnershipMutation { ok: Boolean } +""" +Re-wrap THIS org's keyring after the caller proves they hold the +recovery mnemonic. Used by SSO recovery (where there's no login +password to verify against, so identity is proven via the mnemonic +alone). + +Requires identity_key matching the org's stored identity_key — proves +the caller derived the keyring from the right mnemonic. Without this +proof, an authenticated user (or session-cookie holder) could +overwrite their own wrapped_keyring with arbitrary garbage and lock +themselves out of the org permanently. +""" type UpdateUserWrappedSecretsMutation { orgMember: OrganisationMemberType } +""" +Re-wrap THIS org's keyring after verifying identity via the +recovery mnemonic. The supplied password MUST match the user's +current login auth — auth and sudo passwords stay unified, period. + +Two server-side proofs are required: + 1. identity_key matches the org's stored identity_key — proves the + caller derived the keyring from the right mnemonic. + 2. new_auth_hash matches user.password — proves the password the + user is wrapping the keyring with is also their account login + auth. + +A failed (2) means the user is trying to set a different password +for this org's keyring than what authenticates them. We never +persist that — it would split auth and sudo apart. +""" +type ResetAccountPasswordViaRecoveryMutation { + orgMember: OrganisationMemberType +} + type DeleteInviteMutation { ok: Boolean } diff --git a/frontend/app/[team]/recovery/page.tsx b/frontend/app/[team]/recovery/page.tsx index ab782bc01..a71e10309 100644 --- a/frontend/app/[team]/recovery/page.tsx +++ b/frontend/app/[team]/recovery/page.tsx @@ -114,6 +114,7 @@ export default function Recovery({ params }: { params: { team: string } }) { await updateWrappedSecrets({ variables: { orgId: org!.id, + identityKey: accountKeyRing.publicKey, wrappedKeyring: encryptedKeyring, wrappedRecovery: encryptedMnemonic, }, diff --git a/frontend/contexts/organisationContext.tsx b/frontend/contexts/organisationContext.tsx index 90018aaa0..30feefc8e 100644 --- a/frontend/contexts/organisationContext.tsx +++ b/frontend/contexts/organisationContext.tsx @@ -67,9 +67,17 @@ export const OrganisationProvider: React.FC = ({ chil const localKeyring = getLocalKeyring(session?.user?.email!, org.id) if (localKeyring?.keyring && localKeyring?.recovery) { + // Legacy grandfather path — wrapped_keyring is currently empty + // server-side and we're copying the user's localStorage copy in. + // We don't have the unlocked keyring here to derive identityKey + // from, so echo the server's stored value. The mutation's + // identity_key check is a no-op for this call (the security + // relevant proof happens in the recovery flow, where the value + // is derived from the user's mnemonic input). updateWrappedSecrets({ variables: { orgId: org.id, + identityKey: org.identityKey, wrappedKeyring: localKeyring.keyring, wrappedRecovery: localKeyring.recovery, }, diff --git a/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql b/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql index 18bafaf71..95f5a8237 100644 --- a/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql +++ b/frontend/graphql/mutations/auth/resetAccountPasswordViaRecovery.gql @@ -1,4 +1,4 @@ -mutation ResetAccountPasswordViaRecovery( +mutation ResetPasswordViaRecovery( $orgId: ID! $newAuthHash: String! $identityKey: String! diff --git a/frontend/graphql/mutations/organisation/updateUserWrappedSecrets.gql b/frontend/graphql/mutations/organisation/updateUserWrappedSecrets.gql index c19916004..fd774961d 100644 --- a/frontend/graphql/mutations/organisation/updateUserWrappedSecrets.gql +++ b/frontend/graphql/mutations/organisation/updateUserWrappedSecrets.gql @@ -1,5 +1,15 @@ -mutation UpdateWrappedSecrets($orgId: ID!, $wrappedKeyring: String!, $wrappedRecovery: String!) { - updateMemberWrappedSecrets(orgId: $orgId, wrappedKeyring: $wrappedKeyring, wrappedRecovery: $wrappedRecovery) { +mutation UpdateWrappedSecrets( + $orgId: ID! + $identityKey: String! + $wrappedKeyring: String! + $wrappedRecovery: String! +) { + updateMemberWrappedSecrets( + orgId: $orgId + identityKey: $identityKey + wrappedKeyring: $wrappedKeyring + wrappedRecovery: $wrappedRecovery + ) { orgMember { id } diff --git a/frontend/utils/crypto/users.ts b/frontend/utils/crypto/users.ts index 07d1b3664..23d6e0905 100644 --- a/frontend/utils/crypto/users.ts +++ b/frontend/utils/crypto/users.ts @@ -91,10 +91,17 @@ export const deviceVaultKey = async (password: string, email: string): Promise Date: Sun, 26 Apr 2026 17:17:32 +0530 Subject: [PATCH 068/100] fix(sso): resolve UserToken.user as OrganisationMember PK in middleware token-org lookup --- backend/backend/graphene/middleware.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/backend/graphene/middleware.py b/backend/backend/graphene/middleware.py index bbca4d600..46ede8e4c 100644 --- a/backend/backend/graphene/middleware.py +++ b/backend/backend/graphene/middleware.py @@ -173,16 +173,18 @@ def _lookup_token_org(cls, request, token_id): org_id = None try: + # UserToken.user is a FK to OrganisationMember (not CustomUser), + # so ut.user_id is an OrganisationMember PK. Look up the member + # by .id, not .user_id (which would compare against CustomUser + # PKs and never match). ut = UserToken.objects.only("user_id").get(id=token_id) - member = ( - OrganisationMember.objects.filter( - user_id=ut.user_id, deleted_at__isnull=True + try: + member = OrganisationMember.objects.only("organisation_id").get( + id=ut.user_id, deleted_at__isnull=True ) - .only("organisation_id") - .first() - ) - if member: org_id = str(member.organisation_id) + except OrganisationMember.DoesNotExist: + pass except UserToken.DoesNotExist: pass From 82a66a27e4e91d43388bdd8f58ce7b0893ddd5ff Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:17:35 +0530 Subject: [PATCH 069/100] fix(sso): pass expected_nonce to id_token verification in EE OIDC adapters --- backend/ee/authentication/sso/oidc/okta/views.py | 12 +++++++++++- .../ee/authentication/sso/oidc/util/google/views.py | 11 ++++++++++- .../authentication/sso/oidc/util/jumpcloud/views.py | 11 ++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/backend/ee/authentication/sso/oidc/okta/views.py b/backend/ee/authentication/sso/oidc/okta/views.py index 4cf10c97f..371d42906 100644 --- a/backend/ee/authentication/sso/oidc/okta/views.py +++ b/backend/ee/authentication/sso/oidc/okta/views.py @@ -62,7 +62,17 @@ def complete_login(self, request, app, token, **kwargs): if not id_token and isinstance(kwargs.get("response"), dict): id_token = kwargs["response"].get("id_token") - extra_data = self._get_user_data(token, id_token, app) + # Forward the OIDC nonce so the parent's _process_id_token + # actually validates it. Without this kwarg the check is + # silently skipped (defaults to None → guard short-circuits). + expected_nonce = ( + request.session.get("sso_nonce") + if hasattr(request, "session") + else None + ) + extra_data = self._get_user_data( + token, id_token, app, expected_nonce=expected_nonce + ) logger.debug( f"User authentication data received for email: {extra_data.get('email')}" ) diff --git a/backend/ee/authentication/sso/oidc/util/google/views.py b/backend/ee/authentication/sso/oidc/util/google/views.py index d1e297628..ef97ac132 100644 --- a/backend/ee/authentication/sso/oidc/util/google/views.py +++ b/backend/ee/authentication/sso/oidc/util/google/views.py @@ -47,7 +47,16 @@ def complete_login(self, request, app, token, **kwargs): if not id_token and isinstance(kwargs.get("response"), dict): id_token = kwargs["response"].get("id_token") - extra_data = self._get_user_data(token, id_token, app) + # Forward the OIDC nonce so the parent's _process_id_token + # actually validates it. + expected_nonce = ( + request.session.get("sso_nonce") + if hasattr(request, "session") + else None + ) + extra_data = self._get_user_data( + token, id_token, app, expected_nonce=expected_nonce + ) logger.debug( f"User authentication data received for email: {extra_data.get('email')}" ) diff --git a/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py b/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py index c52e122de..5103afdad 100644 --- a/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py +++ b/backend/ee/authentication/sso/oidc/util/jumpcloud/views.py @@ -49,7 +49,16 @@ def complete_login(self, request, app, token, **kwargs): if not id_token and isinstance(kwargs.get("response"), dict): id_token = kwargs["response"].get("id_token") - extra_data = self._get_user_data(token, id_token, app) + # Forward the OIDC nonce so the parent's _process_id_token + # actually validates it. + expected_nonce = ( + request.session.get("sso_nonce") + if hasattr(request, "session") + else None + ) + extra_data = self._get_user_data( + token, id_token, app, expected_nonce=expected_nonce + ) logger.debug( f"User authentication data received for email: {extra_data.get('email')}" ) From bc5602fe6177d162f874338f5f13ba3e4c7caf27 Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:17:39 +0530 Subject: [PATCH 070/100] fix(sso): clear stale sso_org_config_id on instance-level authorize --- backend/api/views/sso.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/api/views/sso.py b/backend/api/views/sso.py index 1dffb5554..8daeba398 100644 --- a/backend/api/views/sso.py +++ b/backend/api/views/sso.py @@ -648,6 +648,10 @@ def get(self, request, provider): status=404, ) + # Clear any stale org-SSO marker from an abandoned org-level flow + # so the callback dispatches as instance-level. + request.session.pop("sso_org_config_id", None) + config = SSO_PROVIDER_REGISTRY[provider] callback_url = _get_callback_url(provider) From 2bab850af3c37d8471e86d3fda5c45ac5652d41a Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:18:34 +0530 Subject: [PATCH 071/100] fix(sso): route testOrganisationSsoProvider discovery through _safe_oidc_request --- backend/backend/graphene/mutations/sso.py | 7 +++++-- backend/tests/test_org_sso.py | 9 ++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/backend/graphene/mutations/sso.py b/backend/backend/graphene/mutations/sso.py index 3259d50de..ca188a8f6 100644 --- a/backend/backend/graphene/mutations/sso.py +++ b/backend/backend/graphene/mutations/sso.py @@ -214,7 +214,7 @@ class Arguments: @classmethod def mutate(cls, root, info, provider_id): - import requests as http_requests + from api.views.sso import _safe_oidc_request user = info.context.user provider = OrganisationSSOProvider.objects.get(id=provider_id) @@ -241,8 +241,11 @@ def mutate(cls, root, info, provider_id): ) discovery_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" + # Route through _safe_oidc_request so a 302 redirect from a public + # issuer can't pivot the fetch to an internal target (cloud) — the + # helper sets allow_redirects=False and re-validates URLs on cloud. try: - resp = http_requests.get(discovery_url, timeout=10) + resp = _safe_oidc_request("GET", discovery_url, timeout=10) resp.raise_for_status() data = resp.json() if "authorization_endpoint" not in data or "token_endpoint" not in data: diff --git a/backend/tests/test_org_sso.py b/backend/tests/test_org_sso.py index 3fd80ed42..1c8b0341f 100644 --- a/backend/tests/test_org_sso.py +++ b/backend/tests/test_org_sso.py @@ -1430,7 +1430,6 @@ def test_cloud_blocks_private_rfc1918_issuer(self, mock_perm, mock_provider_cls) @patch("backend.graphene.mutations.sso.user_has_permission", return_value=True) def test_self_hosted_skips_ip_check(self, mock_perm, mock_provider_cls): """Self-hosted admin with an internal IdP should be allowed to test.""" - import requests as http_requests from backend.graphene.mutations.sso import TestOrganisationSSOProviderMutation provider = MagicMock() @@ -1451,7 +1450,8 @@ def test_self_hosted_skips_ip_check(self, mock_perm, mock_provider_cls): info = MagicMock() info.context.user = MagicMock() - with patch.object(http_requests, "get", return_value=fake_resp): + # Patch the underlying HTTP layer used by _safe_oidc_request. + with patch("api.views.sso.http_requests.request", return_value=fake_resp): result = TestOrganisationSSOProviderMutation.mutate( None, info, provider_id="p1" ) @@ -1481,9 +1481,8 @@ def test_returns_generic_error_on_upstream_failure( with patch( "api.utils.network.socket.gethostbyname_ex", return_value=("okta.com", [], ["1.1.1.1"]), - ), patch.object( - http_requests, - "get", + ), patch( + "api.views.sso.http_requests.request", side_effect=Exception("INTERNAL SECRET"), ): info = MagicMock() From 69c09c831ff93db26ce1d547e61c8b11f66d8d4b Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:19:19 +0530 Subject: [PATCH 072/100] fix(auth): reject protocol-relative callbackUrl after login --- frontend/components/auth/SignInButtons.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/components/auth/SignInButtons.tsx b/frontend/components/auth/SignInButtons.tsx index fef4f5892..bb4a38d4f 100644 --- a/frontend/components/auth/SignInButtons.tsx +++ b/frontend/components/auth/SignInButtons.tsx @@ -223,7 +223,12 @@ export default function SignInButtons({ setDeviceKey(response.data.userId, deviceKey) } const callbackUrl = searchParams?.get('callbackUrl') - window.location.href = callbackUrl?.startsWith('/') ? callbackUrl : '/' + // Same-origin relative paths only. Protocol-relative URLs like + // //evil.com/phish would be cross-origin and let an attacker + // hijack the post-login navigation. + const isSafeCallback = + !!callbackUrl && callbackUrl.startsWith('/') && !callbackUrl.startsWith('//') + window.location.href = isSafeCallback ? (callbackUrl as string) : '/' } } catch (err) { if (axios.isAxiosError(err) && err.response) { From 47a2b8c8f51e9cf9fc34dee934180eb94309cbdb Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:20:12 +0530 Subject: [PATCH 073/100] fix(auth): verify password before active-state check to prevent email enumeration --- backend/api/views/auth_password.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index ff5f3a8cc..9541350cb 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -331,12 +331,16 @@ def password_login(request): except User.DoesNotExist: return JsonResponse({"error": "Invalid email or password."}, status=401) - if not user.active: - return JsonResponse({"error": "Please verify your email address first."}, status=403) - + # Verify password BEFORE leaking the active/inactive distinction. + # Returning a different status for unverified-but-existing accounts + # would let unauthenticated callers enumerate registered emails in + # the verification window. if not user.check_password(auth_hash): return JsonResponse({"error": "Invalid email or password."}, status=401) + if not user.active: + return JsonResponse({"error": "Please verify your email address first."}, status=403) + login(request, user) request.session["auth_method"] = "password" request.session.pop("auth_sso_org_id", None) From 4f10f86ff98dd2f68c3b5e1ed1e4b40323126ea4 Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:20:41 +0530 Subject: [PATCH 074/100] fix(sso): scope email_check SSO to invite org and disambiguate duplicate provider buttons --- backend/api/views/auth_password.py | 156 +++++++++++++-------- backend/tests/test_org_sso.py | 2 + frontend/components/auth/SignInButtons.tsx | 16 ++- 3 files changed, 117 insertions(+), 57 deletions(-) diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py index 9541350cb..1e5871a0b 100644 --- a/backend/api/views/auth_password.py +++ b/backend/api/views/auth_password.py @@ -36,6 +36,7 @@ class CsrfExemptSessionAuthentication(SessionAuthentication): def enforce_csrf(self, request): return # Skip CSRF check + from django.db.models import Q from api.models import ( @@ -53,12 +54,13 @@ def enforce_csrf(self, request): # --- Rate Limiting --- + class PasswordRegisterThrottle(AnonRateThrottle): - rate = "5/hour" + rate = "5/min" class PasswordChangeThrottle(AnonRateThrottle): - rate = "5/hour" + rate = "5/min" class AuthLoginThrottle(AnonRateThrottle): @@ -70,11 +72,12 @@ class EmailCheckThrottle(AnonRateThrottle): class ResendVerificationThrottle(AnonRateThrottle): - rate = "3/hour" + rate = "3/min" # --- Helpers --- + def _skip_email_verification(): """Check if email verification is disabled (for quick self-hosted setup).""" return os.getenv("SKIP_EMAIL_VERIFICATION", "").lower() in ("true", "1", "yes") @@ -129,6 +132,7 @@ def _send_verification_email(email, verify_url): # --- Endpoints --- + @csrf_exempt @api_view(["POST"]) @authentication_classes([]) @@ -156,10 +160,15 @@ def password_register(request): return JsonResponse({"error": "Invalid email address."}, status=400) if not _check_email_domain_allowed(email): - return JsonResponse({"error": "Registration is not available for this email domain."}, status=403) + return JsonResponse( + {"error": "Registration is not available for this email domain."}, + status=403, + ) if User.objects.filter(email=email).exists(): - return JsonResponse({"error": "An account with this email already exists."}, status=409) + return JsonResponse( + {"error": "An account with this email already exists."}, status=409 + ) # If signup was triggered from an invite link, the registered email must # match the invitee email. The frontend forwards callbackUrl=/invite/ @@ -169,8 +178,9 @@ def password_register(request): callback_url = data.get("callbackUrl") or "" if callback_url.startswith("/invite/"): from base64 import b64decode + try: - encoded_invite = callback_url[len("/invite/"):].split("/")[0].split("?")[0] + encoded_invite = callback_url[len("/invite/") :].split("/")[0].split("?")[0] invite_id = b64decode(encoded_invite).decode("utf-8") invite = OrganisationMemberInvite.objects.get( id=invite_id, @@ -212,7 +222,9 @@ def password_register(request): ) if skip_verification: - return JsonResponse({"message": "Account created.", "verificationSkipped": True}, status=201) + return JsonResponse( + {"message": "Account created.", "verificationSkipped": True}, status=201 + ) # Send verification email (outside transaction so the user is persisted). # If the send fails, the user can still use the resend endpoint. @@ -224,6 +236,7 @@ def password_register(request): next_url = _safe_internal_path(data.get("callbackUrl")) if next_url: from urllib.parse import urlencode + verify_url = f"{verify_url}?{urlencode({'next': next_url})}" try: _send_verification_email(email, verify_url) @@ -286,10 +299,18 @@ def resend_verification(request): user = User.objects.get(email=email) except User.DoesNotExist: # Don't reveal whether email exists - return JsonResponse({"message": "If that email is registered, a new verification link has been sent."}) + return JsonResponse( + { + "message": "If that email is registered, a new verification link has been sent." + } + ) if user.active: - return JsonResponse({"message": "If that email is registered, a new verification link has been sent."}) + return JsonResponse( + { + "message": "If that email is registered, a new verification link has been sent." + } + ) # Delete old token, create new one EmailVerification.objects.filter(user=user).delete() @@ -304,7 +325,11 @@ def resend_verification(request): verify_url = f"{backend_url}/auth/verify-email/{token}/" _send_verification_email(email, verify_url) - return JsonResponse({"message": "If that email is registered, a new verification link has been sent."}) + return JsonResponse( + { + "message": "If that email is registered, a new verification link has been sent." + } + ) @csrf_exempt @@ -339,7 +364,9 @@ def password_login(request): return JsonResponse({"error": "Invalid email or password."}, status=401) if not user.active: - return JsonResponse({"error": "Please verify your email address first."}, status=403) + return JsonResponse( + {"error": "Please verify your email address first."}, status=403 + ) login(request, user) request.session["auth_method"] = "password" @@ -363,6 +390,7 @@ def password_login(request): try: from api.emails import send_login_email + send_login_email(request, user.email, full_name or user.email, "Password") except Exception as email_err: logger.error(f"Failed to send password login email: {email_err}") @@ -415,11 +443,13 @@ def password_change(request): wrapped_keyring = data.get("wrappedKeyring", "") wrapped_recovery = data.get("wrappedRecovery", "") - if not all([current_auth_hash, new_auth_hash, org_id, identity_key, wrapped_keyring]): + if not all( + [current_auth_hash, new_auth_hash, org_id, identity_key, wrapped_keyring] + ): return JsonResponse( { "error": "currentAuthHash, newAuthHash, orgId, identityKey, and " - "wrappedKeyring are required." + "wrappedKeyring are required." }, status=400, ) @@ -479,9 +509,7 @@ def invite_lookup(request, invite_id): adds an extra layer. """ try: - invite = OrganisationMemberInvite.objects.select_related( - "organisation" - ).get( + invite = OrganisationMemberInvite.objects.select_related("organisation").get( id=invite_id, valid=True, expires_at__gt=timezone.now(), @@ -489,10 +517,12 @@ def invite_lookup(request, invite_id): except OrganisationMemberInvite.DoesNotExist: return JsonResponse({"error": "Invite not found or expired."}, status=404) - return JsonResponse({ - "inviteeEmail": invite.invitee_email, - "organisationName": invite.organisation.name, - }) + return JsonResponse( + { + "inviteeEmail": invite.invitee_email, + "organisationName": invite.organisation.name, + } + ) @csrf_exempt @@ -529,18 +559,16 @@ def email_check(request): user = None has_password = True - # Build the provider query from (a) the user's existing org memberships - # and (b) any pending invite they're resolving. Either, neither, or both - # may apply. - provider_filters = [] - if user is not None: - provider_filters.append( - Q( - organisation__users__user=user, - organisation__users__deleted_at=None, - ) - ) - + # In the invite-acceptance flow the only useful SSO is the invite's + # org's SSO — authenticating via another org's provider would land + # the user with an SSO session bound to the wrong org, locking them + # out of the org they're actually trying to join. Membership-derived + # SSO is only useful as a FALLBACK when the invite's org has no SSO + # configured (so existing users without a password can still sign in). + # + # Outside the invite flow we always offer membership-derived SSO so + # returning users can pick whichever org they want to land in. + invite_org_filter = None if invite_id: try: invite = OrganisationMemberInvite.objects.get( @@ -549,34 +577,52 @@ def email_check(request): expires_at__gt=timezone.now(), invitee_email__iexact=email, ) - provider_filters.append(Q(organisation=invite.organisation)) + invite_org_filter = Q(organisation=invite.organisation) except OrganisationMemberInvite.DoesNotExist: pass - sso_methods = [] - if provider_filters: - combined = provider_filters[0] - for q in provider_filters[1:]: - combined = combined | q - org_providers = ( - OrganisationSSOProvider.objects.filter(combined, enabled=True) + membership_filter = None + if user is not None: + membership_filter = Q( + organisation__users__user=user, + organisation__users__deleted_at=None, + ) + + def _query(filter_q): + return list( + OrganisationSSOProvider.objects.filter(filter_q, enabled=True) .select_related("organisation") .distinct() ) - sso_methods = [ - { - "id": str(provider.id), - "providerType": "oidc", - "provider": provider.provider_type, - "providerName": provider.name, - "enforced": provider.organisation.require_sso, - } - for provider in org_providers - ] - return JsonResponse({ - "authMethods": { - "password": has_password, - "sso": sso_methods, + org_providers = [] + if invite_org_filter is not None: + org_providers = _query(invite_org_filter) + # Fallback: if the invite's org has no SSO, fall back to the user's + # account-level auth methods so an existing-user invitee with no + # password set isn't stranded. + if not org_providers and membership_filter is not None: + org_providers = _query(membership_filter) + elif membership_filter is not None: + org_providers = _query(membership_filter) + + sso_methods = [ + { + "id": str(provider.id), + "providerType": "oidc", + "provider": provider.provider_type, + "providerName": provider.name, + "organisationName": provider.organisation.name, + "enforced": provider.organisation.require_sso, } - }) + for provider in org_providers + ] + + return JsonResponse( + { + "authMethods": { + "password": has_password, + "sso": sso_methods, + } + } + ) diff --git a/backend/tests/test_org_sso.py b/backend/tests/test_org_sso.py index 1c8b0341f..d20b6f22b 100644 --- a/backend/tests/test_org_sso.py +++ b/backend/tests/test_org_sso.py @@ -119,6 +119,7 @@ def test_user_with_org_sso_returns_provider( org = MagicMock() org.require_sso = False + org.name = "Acme Corp" provider = MagicMock() provider.id = "test-config-id" @@ -163,6 +164,7 @@ def test_enforced_sso_marked(self, mock_get_user, mock_om, mock_sso_provider): org = MagicMock() org.require_sso = True + org.name = "Acme Corp" provider = MagicMock() provider.id = "enforced-id" diff --git a/frontend/components/auth/SignInButtons.tsx b/frontend/components/auth/SignInButtons.tsx index bb4a38d4f..d5c92dcd1 100644 --- a/frontend/components/auth/SignInButtons.tsx +++ b/frontend/components/auth/SignInButtons.tsx @@ -76,6 +76,7 @@ type SSOMethod = { providerType: 'instance' | 'oidc' provider?: string providerName?: string + organisationName?: string enforced: boolean } @@ -483,8 +484,15 @@ export default function SignInButtons({ } } + // Disambiguate when the user has multiple org-level + // providers — same provider name across different + // orgs would otherwise render as identical buttons. + const needsOrgSuffix = + isOrg && + method.organisationName && + ssoMethods.filter((m) => m.providerName === method.providerName).length > 1 const label = isOrg - ? `Sign in with ${method.providerName || 'SSO'}` + ? `Sign in with ${method.providerName || 'SSO'}${needsOrgSuffix ? ` — ${method.organisationName}` : ''}` : `Sign in with ${getProviderName(method.id)}` const icon = isOrg ? (method.provider ? orgProviderIcons[method.provider] : undefined) @@ -548,8 +556,12 @@ export default function SignInButtons({ } } + const needsOrgSuffix = + isOrg && + method.organisationName && + ssoMethods.filter((m) => m.providerName === method.providerName).length > 1 const label = isOrg - ? `Continue with ${method.providerName || 'SSO'}` + ? `Continue with ${method.providerName || 'SSO'}${needsOrgSuffix ? ` — ${method.organisationName}` : ''}` : `Continue with ${getProviderName(method.id)}` const icon = isOrg ? (method.provider ? orgProviderIcons[method.provider] : undefined) From 5f5296a57f2a7505bf478a5617a4ed3cdd5077de Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 26 Apr 2026 17:25:10 +0530 Subject: [PATCH 075/100] fix: misc styling and ux polish to signup and onboarding screens Signed-off-by: rohan --- frontend/app/[team]/access/sso/oidc/page.tsx | 90 +++++++------------ frontend/app/[team]/recovery/page.tsx | 4 +- frontend/app/invite/[invite]/page.tsx | 10 +-- frontend/app/onboard/page.tsx | 14 ++- frontend/app/signup/page.tsx | 24 +---- .../common/PasswordStrengthMeter.tsx | 6 +- .../components/onboarding/AccountPassword.tsx | 9 +- frontend/components/onboarding/TeamName.tsx | 5 +- 8 files changed, 56 insertions(+), 106 deletions(-) diff --git a/frontend/app/[team]/access/sso/oidc/page.tsx b/frontend/app/[team]/access/sso/oidc/page.tsx index 5d7b150e1..b88ea1ea1 100644 --- a/frontend/app/[team]/access/sso/oidc/page.tsx +++ b/frontend/app/[team]/access/sso/oidc/page.tsx @@ -23,16 +23,7 @@ import { Avatar } from '@/components/common/Avatar' import { UpsellDialog } from '@/components/settings/organisation/UpsellDialog' import { PlanLabel } from '@/components/settings/organisation/PlanLabel' import { ApiOrganisationPlanChoices } from '@/apollo/graphql' -import { - FaBan, - FaCheckCircle, - FaShieldAlt, - FaTrashAlt, - FaPen, - FaSignInAlt, - FaToggleOn, - FaToggleOff, -} from 'react-icons/fa' +import { FaBan, FaCheckCircle, FaShieldAlt, FaTrashAlt, FaPen, FaSignInAlt } from 'react-icons/fa' const PROVIDER_INFO = { entra_id: { @@ -66,9 +57,7 @@ export default function OIDCPage({ params }: { params: { team: string } }) { }) // Find this org's data from the organisations list - const orgData = data?.organisations?.find( - (o: any) => o.id === organisation?.id - ) + const orgData = data?.organisations?.find((o: any) => o.id === organisation?.id) const ssoProviders = orgData?.ssoProviders || [] const requireSso = orgData?.requireSso || false const serverPublicKey = data?.serverPublicKey || '' @@ -184,8 +173,7 @@ export default function OIDCPage({ params }: { params: { team: string } }) { // also see the upgrade prompt rather than "access restricted" — clearer // signal about *why* SSO is unavailable). Self-hosted orgs without an // Enterprise license also land here; UpsellDialog shows contact-us copy. - const planAllowsSSO = - organisation?.plan === ApiOrganisationPlanChoices.En + const planAllowsSSO = organisation?.plan === ApiOrganisationPlanChoices.En if (organisation && !planAllowsSSO) { return ( @@ -359,10 +347,7 @@ export default function OIDCPage({ params }: { params: { team: string } }) { Test SSO - @@ -409,7 +394,9 @@ export default function OIDCPage({ params }: { params: { team: string } }) { {relativeTimeFromDates(new Date(provider.createdAt))} by - {provider.createdBy.fullName} + + {provider.createdBy.fullName} +
)} {provider.updatedBy && provider.updatedAt !== provider.createdAt && ( @@ -418,7 +405,9 @@ export default function OIDCPage({ params }: { params: { team: string } }) { {relativeTimeFromDates(new Date(provider.updatedAt))} by - {provider.updatedBy.fullName} + + {provider.updatedBy.fullName} +
)}
@@ -525,7 +514,10 @@ export default function OIDCPage({ params }: { params: { team: string } }) {

Are you sure you want to delete{' '} - {deletingProvider?.name}? + + {deletingProvider?.name} + + ? {deletingProvider?.enabled && ( {' '} @@ -535,10 +527,7 @@ export default function OIDCPage({ params }: { params: { team: string } }) { )}

-
@@ -683,22 +660,19 @@ export default function OIDCPage({ params }: { params: { team: string } }) {

  • - Members who signed in via this provider will not be able to log in until - another SSO provider is enabled or they reset their password. + Members who signed in via this provider will not be able to log in until another SSO + provider is enabled or they reset their password.
  • {requireSso && (
  • - SSO enforcement is currently active — deactivating this provider will also - turn off enforcement, allowing password login. + SSO enforcement is currently active — deactivating this provider will also turn + off enforcement, allowing password login.
  • )}
-
diff --git a/frontend/components/common/PasswordStrengthMeter.tsx b/frontend/components/common/PasswordStrengthMeter.tsx index 157c910ad..4e1b63c5c 100644 --- a/frontend/components/common/PasswordStrengthMeter.tsx +++ b/frontend/components/common/PasswordStrengthMeter.tsx @@ -44,10 +44,8 @@ export const PasswordStrengthMeter = ({ password }: PasswordStrengthMeterProps) style={{ transform: `scaleX(${percent})`, transformOrigin: '0%' }} />
-
-
- {isStrong ? : } -
+
+
{isStrong ? : }
{isStrong ? ( 'Strong password' diff --git a/frontend/components/onboarding/AccountPassword.tsx b/frontend/components/onboarding/AccountPassword.tsx index 1057e5243..4becebfde 100644 --- a/frontend/components/onboarding/AccountPassword.tsx +++ b/frontend/components/onboarding/AccountPassword.tsx @@ -20,8 +20,8 @@ export const AccountPassword = (props: AccountPasswordProps) => { return (
-