diff --git a/.env.dev.example b/.env.dev.example
index eb2340411..7d4f7381f 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -1,27 +1,27 @@
-# Securely manage and sync environment variables with Phase.
+# phase.dev - Keep secrets.
+#
+# /$$
+# | $$
+# /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
+# /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$
+# | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$
+# | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/
+# | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$
+# | $$____/ |__/ |__/ \_______/|_______/ \_______/
+# | $$
+# |__/
+#
+# For the complete list of secrets and deployment configuration options, see:
+# https://docs.phase.dev/self-hosting/configuration/envars
+# Warning: For production deployments, use a more secure method than a .env file to store secrets.
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠔⠋⣳⣖⠚⣲⢖⠙⠳⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⡴⠉⢀⡼⠃⢘⣞⠁⠙⡆⠀⠘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⢀⡜⠁⢠⠞⠀⢠⠞⠸⡆⠀⠹⡄⠀⠹⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⢀⠞⠀⢠⠏⠀⣠⠏⠀⠀⢳⠀⠀⢳⠀⠀⢧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⢠⠎⠀⣠⠏⠀⣰⠃⠀⠀⠀⠈⣇⠀⠘⡇⠀⠘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⢠⠏⠀⣰⠇⠀⣰⠃⠀⠀⠀⠀⠀⢺⡀⠀⢹⠀⠀⢽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⢠⠏⠀⣰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⣇⠀⠈⣇⠀⠘⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⢠⠏⠀⢰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⠀⢸⡀⠀⢹⡀⠀⢹⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⢠⠏⠀⢰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣇⠀⠈⣇⠀⠈⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠛⠒⠚⠛⠒⠓⠚⠒⠒⠓⠒⠓⠚⠒⠓⠚⠒⠓⢻⡒⠒⢻⡒⠒⢻⡒⠒⠒⠒⠒⠒⠒⠒⠒⠒⣲⠒⠒⣲⠒⠒⡲⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢧⠀⠀⢧⠀⠈⣇⠀⠀⠀⠀⠀⠀⠀⠀⢠⠇⠀⣰⠃⠀⣰⠃⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡆⠀⠘⡆⠀⠸⡄⠀⠀⠀⠀⠀⠀⣠⠇⠀⣰⠃⠀⣴⠃⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⡄⠀⠹⡄⠀⠹⡄⠀⠀⠀⠀⡴⠃⢀⡼⠁⢀⡼⠁⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠀⠙⣆⠀⠹⣄⠀⣠⠎⠁⣠⠞⠀⡤⠏⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⢤⣈⣳⣤⣼⣹⢥⣰⣋⡥⡴⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
-
-# Replace with your domain or host
+# Replace with your domain or host.
+# Use the domain or IP address where users will access Phase.
HOST=localhost
HTTP_PROTOCOL=https://
-# Whitelist email domains that users are allowed to sign-in with, as a comma separated list.
-# Leave commented to allow all email domains
+# Whitelist email domains that users are allowed to sign in with, as a comma-separated list.
+# Leave commented to allow all email domains.
#USER_EMAIL_DOMAIN_WHITELIST=mydomain.com,subdomain.mydomain.com
# Frontend dev
@@ -29,14 +29,22 @@ NEXTAUTH_URL=https://localhost
OAUTH_REDIRECT_URI=https://localhost
BACKEND_API_BASE=http://backend:8000
NEXT_PUBLIC_BACKEND_API_BASE=https://localhost/service
-SSO_PROVIDERS=google,github,gitlab
-# WARNING: Replace these with a cryptographically strong random values. You can use `openssl rand -hex 32` to generate these.
+# OAuth providers to enable on the sign-in page (optional).
+# Leave empty for password-only local development.
+# Example: SSO_PROVIDERS=google,github,gitlab
+SSO_PROVIDERS=
+
+# Allow new users to sign themselves up (default: true). Set to "false"
+# to require an invite for any new account.
+#ALLOW_SIGNUPS=true
+
+# WARNING: Replace these with cryptographically strong random values. You can use `openssl rand -hex 32` to generate them.
NEXTAUTH_SECRET=82031b3760ac58352bb2d48fd9f32e9f72a0614343b669038139f18652ed1447
SECRET_KEY=92d44efc4f9a4c0556cc67d2d033d3217829c263d5ab7d1954cf4b5bfd533e58
SERVER_SECRET=9e760539415af07b22249b5878593bd4deb9b8961c7dd0570117549f2c4f32a2
-# OAuth provider credentials. Add your own credentials here for each provider you wish to use
+# OAuth provider credentials. Add credentials for each provider you enable in SSO_PROVIDERS.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -48,6 +56,7 @@ GITLAB_CLIENT_SECRET=
# Integrations
+# GitHub secret sync integration OAuth credentials.
GITHUB_INTEGRATION_CLIENT_ID=
NEXT_PUBLIC_GITHUB_INTEGRATION_CLIENT_ID=
GITHUB_INTEGRATION_CLIENT_SECRET=
@@ -57,7 +66,7 @@ ALLOWED_HOSTS=localhost,backend
ALLOWED_ORIGINS=https://localhost
SESSION_COOKIE_DOMAIN=localhost
-# Database credentials. Change all these values if required, but you may need to update the healthcheck in dev-docker-compose.yml services.postgres as well
+# Database credentials. Change these values as needed, but update the postgres healthcheck in dev-docker-compose.yml as well.
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=postgres-db-name
@@ -67,6 +76,3 @@ DATABASE_PASSWORD=postgres-password
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
-
-# Disable NextJs telemtry
-NEXT_TELEMETRY_DISABLED=1
diff --git a/.env.example b/.env.example
index a48a40c61..3bf283018 100644
--- a/.env.example
+++ b/.env.example
@@ -1,40 +1,46 @@
-# Securely manage and sync environment variables with Phase.
+# phase.dev - Keep secrets.
+#
+# /$$
+# | $$
+# /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$$ /$$$$$$
+# /$$__ $$| $$__ $$ |____ $$ /$$_____/ /$$__ $$
+# | $$ \ $$| $$ \ $$ /$$$$$$$| $$$$$$ | $$$$$$$$
+# | $$ | $$| $$ | $$ /$$__ $$ \____ $$| $$_____/
+# | $$$$$$$/| $$ | $$| $$$$$$$ /$$$$$$$/| $$$$$$$
+# | $$____/ |__/ |__/ \_______/|_______/ \_______/
+# | $$
+# |__/
+#
+# For the complete list of secrets and deployment configuration options, see:
+# https://docs.phase.dev/self-hosting/configuration/envars
+# Warning: For production deployments, use a more secure method than a .env file to store secrets.
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠔⠋⣳⣖⠚⣲⢖⠙⠳⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⡴⠉⢀⡼⠃⢘⣞⠁⠙⡆⠀⠘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⢀⡜⠁⢠⠞⠀⢠⠞⠸⡆⠀⠹⡄⠀⠹⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⢀⠞⠀⢠⠏⠀⣠⠏⠀⠀⢳⠀⠀⢳⠀⠀⢧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⢠⠎⠀⣠⠏⠀⣰⠃⠀⠀⠀⠈⣇⠀⠘⡇⠀⠘⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⢠⠏⠀⣰⠇⠀⣰⠃⠀⠀⠀⠀⠀⢺⡀⠀⢹⠀⠀⢽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⢠⠏⠀⣰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⣇⠀⠈⣇⠀⠘⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⢠⠏⠀⢰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⠀⢸⡀⠀⢹⡀⠀⢹⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⢠⠏⠀⢰⠃⠀⣰⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣇⠀⠈⣇⠀⠈⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠛⠒⠚⠛⠒⠓⠚⠒⠒⠓⠒⠓⠚⠒⠓⠚⠒⠓⢻⡒⠒⢻⡒⠒⢻⡒⠒⠒⠒⠒⠒⠒⠒⠒⠒⣲⠒⠒⣲⠒⠒⡲⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢧⠀⠀⢧⠀⠈⣇⠀⠀⠀⠀⠀⠀⠀⠀⢠⠇⠀⣰⠃⠀⣰⠃⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡆⠀⠘⡆⠀⠸⡄⠀⠀⠀⠀⠀⠀⣠⠇⠀⣰⠃⠀⣴⠃⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⡄⠀⠹⡄⠀⠹⡄⠀⠀⠀⠀⡴⠃⢀⡼⠁⢀⡼⠁⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣆⠀⠙⣆⠀⠹⣄⠀⣠⠎⠁⣠⠞⠀⡤⠏⠀⠀⠀⠀⠀⠀⠀⠀
-# ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⢤⣈⣳⣤⣼⣹⢥⣰⣋⡥⡴⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
-
-
-# Replace with your domain or host
+# Replace with your domain or host.
+# Use the domain or IP address where users will access Phase.
HOST=localhost
HTTP_PROTOCOL=https://
-# Whitelist email domains that users are allowed to sign-in with, as a comma separated list.
-# Leave commented to allow all email domains
+# Whitelist email domains that users are allowed to sign in with, as a comma-separated list.
+# Leave commented to allow all email domains.
#USER_EMAIL_DOMAIN_WHITELIST=mydomain.com,subdomain.mydomain.com
-# WARNING: Replace these with a cryptographically strong random values. You can use `openssl rand -hex 32` to generate these.
+# WARNING: Replace these with cryptographically strong random values. You can use `openssl rand -hex 32` to generate them.
NEXTAUTH_SECRET=82031b3760ac58352bb2d48fd9f32e9f72a0614343b669038139f18652ed1447
SECRET_KEY=92d44efc4f9a4c0556cc67d2d033d3217829c263d5ab7d1954cf4b5bfd533e58
SERVER_SECRET=9e760539415af07b22249b5878593bd4deb9b8961c7dd0570117549f2c4f32a2
-# OAuth providers to enable on sign-in page (remove any that aren't required)
+# OAuth providers to enable on the sign-in page (optional).
+# Leave empty for password-only signup and login.
# Example: SSO_PROVIDERS=google,github,gitlab
-SSO_PROVIDERS=google,github,gitlab
+SSO_PROVIDERS=
+
+# Allow new users to sign themselves up (default: true). Set to "false" to
+# require an invite for any new account — existing users keep signing in
+# normally, and invited emails always pass through. Useful once your team
+# is fully onboarded and you want to close the door on strangers.
+#ALLOW_SIGNUPS=true
-# OAuth provider credentials. Add your own credentials here for each provider you wish to use
+# OAuth provider credentials. Add credentials for each provider you enable in SSO_PROVIDERS.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
@@ -45,11 +51,13 @@ GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Integrations
+# GitHub secret sync integration OAuth credentials.
GITHUB_INTEGRATION_CLIENT_ID=
GITHUB_INTEGRATION_CLIENT_SECRET=
-# Database credentials. Change all these values as required, except DATABASE_HOST
-DATABASE_HOST=postgres # don't change this
+# Database credentials. Change these values as needed, except DATABASE_HOST.
+# Do not change DATABASE_HOST for the default Docker Compose setup.
+DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_NAME=postgres-db-name
DATABASE_USER=postgres-user
@@ -58,7 +66,3 @@ DATABASE_PASSWORD=a765b221799be364c53c8a32acccf5dd90d5fc832607bdd14fccaaaa0062ad
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
-
-
-# Disable NextJs telemtry
-NEXT_TELEMETRY_DISABLED=1
diff --git a/.gitignore b/.gitignore
index 9c9ff6450..6ae654b95 100644
--- a/.gitignore
+++ b/.gitignore
@@ -158,6 +158,7 @@ yarn-error.log*
# local env files
.env*.local
.env.dev
+
# vercel
.vercel
diff --git a/backend/api/apps.py b/backend/api/apps.py
index 66656fd29..2320552ed 100644
--- a/backend/api/apps.py
+++ b/backend/api/apps.py
@@ -4,3 +4,7 @@
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'
+
+ def ready(self):
+ from api.utils.access.org_resolution import register_invalidation_signals
+ register_invalidation_signals()
diff --git a/backend/api/authentication/adapters/generic/views.py b/backend/api/authentication/adapters/generic/views.py
index 232781a1b..646467373 100644
--- a/backend/api/authentication/adapters/generic/views.py
+++ b/backend/api/authentication/adapters/generic/views.py
@@ -3,7 +3,11 @@
import logging
from allauth.socialaccount.providers.oauth2.views import OAuth2Adapter, OAuth2Error
from allauth.socialaccount.models import SocialAccount
+from django.conf import settings
from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+
+from api.utils.network import validate_url_is_safe
logger = logging.getLogger(__name__)
@@ -28,13 +32,26 @@ def _fetch_oidc_config(self):
if not self.oidc_config_url:
raise ValueError("OIDC configuration URL not set")
- oidc_config_response = requests.get(self.oidc_config_url)
+ self._assert_url_safe(self.oidc_config_url)
+ oidc_config_response = requests.get(
+ self.oidc_config_url, allow_redirects=False
+ )
oidc_config = oidc_config_response.json()
- self.access_token_url = oidc_config["token_endpoint"]
+ token_endpoint = oidc_config["token_endpoint"]
+ jwks_uri = oidc_config["jwks_uri"]
+ userinfo_endpoint = oidc_config["userinfo_endpoint"]
+ # Endpoints returned by discovery flow back out to server-side
+ # fetches (token exchange, userinfo, JWKS). Validate before
+ # trusting any of them.
+ self._assert_url_safe(token_endpoint)
+ self._assert_url_safe(jwks_uri)
+ self._assert_url_safe(userinfo_endpoint)
+
+ self.access_token_url = token_endpoint
self.authorize_url = oidc_config["authorization_endpoint"]
- self.profile_url = oidc_config["userinfo_endpoint"]
- self.jwks_url = oidc_config["jwks_uri"]
+ self.profile_url = userinfo_endpoint
+ self.jwks_url = jwks_uri
self.issuer = oidc_config["issuer"]
except Exception as e:
logger.error(f"Failed to fetch OIDC configuration: {e}")
@@ -42,13 +59,31 @@ def _fetch_oidc_config(self):
raise
self._set_default_config()
+ @staticmethod
+ def _assert_url_safe(url):
+ """Reject URLs that resolve to private networks on cloud.
+ Self-hosted deployments may legitimately use internal OIDC."""
+ if settings.APP_HOST != "cloud":
+ return
+ try:
+ validate_url_is_safe(url)
+ except ValidationError:
+ raise OAuth2Error(f"URL rejected by safety check: {url}")
+
def _set_default_config(self):
for key, value in self.default_config.items():
setattr(self, key, value)
- def _get_user_data(self, token, id_token, app):
+ def _get_user_data(self, token, id_token, app, expected_nonce=None):
if id_token:
- return self._process_id_token(id_token, app)
+ return self._process_id_token(id_token, app, expected_nonce=expected_nonce)
+ # Userinfo fallback can't bind to the auth request — refuse if
+ # a nonce was issued (replay would be undetectable).
+ if expected_nonce is not None:
+ raise OAuth2Error(
+ "id_token required for nonce verification; refusing to "
+ "accept userinfo claims without it."
+ )
return self._fetch_user_info(token)
def _fetch_user_info(self, token):
@@ -57,13 +92,13 @@ def _fetch_user_info(self, token):
resp.raise_for_status()
return resp.json()
- def _process_id_token(self, id_token, app):
- jwks_response = requests.get(self.jwks_url)
- jwks = jwks_response.json()
+ def _process_id_token(self, id_token, app, expected_nonce=None):
try:
- return jwt.decode(
+ jwk_client = jwt.PyJWKClient(self.jwks_url)
+ signing_key = jwk_client.get_signing_key_from_jwt(id_token)
+ claims = jwt.decode(
id_token,
- key=jwks,
+ key=signing_key.key,
algorithms=["RS256"],
audience=app.client_id,
issuer=self.issuer,
@@ -72,6 +107,19 @@ def _process_id_token(self, id_token, app):
logger.error(f"ID token validation failed: {e}")
raise OAuth2Error(f"Invalid ID token: {e}")
+ # OIDC nonce: anchors the ID token to the specific authorize
+ # request initiated by this session. Without it, a stolen token
+ # could be replayed. When a nonce was sent, it MUST match.
+ if expected_nonce is not None:
+ if claims.get("nonce") != expected_nonce:
+ logger.error(
+ "ID token nonce mismatch — possible replay or "
+ "cross-session attack"
+ )
+ raise OAuth2Error("Invalid ID token: nonce mismatch")
+
+ return claims
+
def pre_social_login(self, request, sociallogin):
User = get_user_model()
@@ -111,7 +159,14 @@ 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)
+ 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')}"
)
@@ -121,6 +176,8 @@ def complete_login(self, request, app, token, **kwargs):
return login
+ except OAuth2Error:
+ raise
except Exception as e:
logger.error(f"OIDC login failed: {str(e)}")
raise OAuth2Error(str(e))
diff --git a/backend/api/authentication/adapters/github.py b/backend/api/authentication/adapters/github.py
index d58a27a7d..92445f71c 100644
--- a/backend/api/authentication/adapters/github.py
+++ b/backend/api/authentication/adapters/github.py
@@ -1,3 +1,4 @@
+import logging
import requests
from api.emails import send_login_email
from allauth.socialaccount import app_settings
@@ -5,6 +6,8 @@
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
from django.conf import settings
+logger = logging.getLogger(__name__)
+
class CustomGitHubOAuth2Adapter(GitHubOAuth2Adapter):
provider_id = GitHubProvider.id
@@ -27,19 +30,43 @@ def complete_login(self, request, app, token, **kwargs):
resp = requests.get(self.profile_url, headers=headers)
resp.raise_for_status()
extra_data = resp.json()
+ emails = None
if app_settings.QUERY_EMAIL and not extra_data.get("email"):
- emails = self.get_emails(headers)
- if emails:
- # First try to get primary email
- for email_obj in emails:
+ emails = self.get_emails(headers) or []
+ # Only accept verified emails — GitHub allows unverified primary
+ # emails, which would otherwise let an attacker set a victim's
+ # email as primary on their own GitHub account and sign in as
+ # that victim.
+ verified_emails = [e for e in emails if e.get("verified")]
+ if verified_emails:
+ for email_obj in verified_emails:
if email_obj.get("primary"):
extra_data["email"] = email_obj["email"]
break
- # If no primary email found, use the first one
- if not extra_data.get("email") and len(emails) > 0:
- extra_data["email"] = emails[0]["email"]
+ if not extra_data.get("email"):
+ extra_data["email"] = verified_emails[0]["email"]
- email = extra_data["email"]
+ email = extra_data.get("email")
+ if not email:
+ # GitHub's API-level invariant is that a primary email MUST be
+ # verified before it can be set primary, so in practice this
+ # branch should be near-unreachable. If we ever observe this
+ # log in production, investigate whether the invariant has
+ # changed, whether API stale data is involved, or whether a
+ # real user needs support. github_login is safe to log (it's
+ # the public GitHub handle, not the private email).
+ logger.warning(
+ "github_sso_rejected_no_verified_email "
+ "github_login=%s total_emails=%s had_primary_email_in_profile=%s",
+ extra_data.get("login"),
+ len(emails) if emails is not None else "not_queried",
+ bool(extra_data.get("email")),
+ )
+ from allauth.socialaccount.providers.oauth2.client import OAuth2Error
+ raise OAuth2Error(
+ "GitHub returned no verified email address. Please verify "
+ "an email on your GitHub account and retry."
+ )
try:
full_name = extra_data.get("name", email.split("@")[0])
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)
diff --git a/backend/api/migrations/0120_add_emailverification_and_user_full_name.py b/backend/api/migrations/0120_add_emailverification_and_user_full_name.py
new file mode 100644
index 000000000..19374eac9
--- /dev/null
+++ b/backend/api/migrations/0120_add_emailverification_and_user_full_name.py
@@ -0,0 +1,31 @@
+# Generated by Django 4.2.29 on 2026-04-14 06:08
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0119_secretevent_type_field'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='customuser',
+ name='full_name',
+ field=models.CharField(blank=True, default='', max_length=128),
+ ),
+ migrations.CreateModel(
+ name='EmailVerification',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('token', models.CharField(db_index=True, max_length=64, unique=True)),
+ ('verified', models.BooleanField(default=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('expires_at', models.DateTimeField()),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
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/migrations/0122_sso_audit_fks_set_null.py b/backend/api/migrations/0122_sso_audit_fks_set_null.py
new file mode 100644
index 000000000..37e202b4e
--- /dev/null
+++ b/backend/api/migrations/0122_sso_audit_fks_set_null.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.29 on 2026-04-21 08:04
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0121_organisation_require_sso_organisationssoprovider'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='organisationssoprovider',
+ name='created_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sso_providers_created', to='api.organisationmember'),
+ ),
+ migrations.AlterField(
+ model_name='organisationssoprovider',
+ name='updated_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sso_providers_updated', to='api.organisationmember'),
+ ),
+ ]
diff --git a/backend/api/models.py b/backend/api/models.py
index 42bff9074..771be6308 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"]
@@ -66,10 +67,23 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
objects = CustomUserManager()
+ @property
+ def auth_method(self):
+ """'password' if the user signed up with email/password, else 'sso'."""
+ return "password" if self.has_usable_password() else "sso"
+
class Meta:
verbose_name = "Custom User"
+class EmailVerification(models.Model):
+ user = models.OneToOneField(CustomUser, on_delete=models.CASCADE)
+ token = models.CharField(max_length=64, unique=True, db_index=True)
+ verified = models.BooleanField(default=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ expires_at = models.DateTimeField()
+
+
class Organisation(models.Model):
FREE_PLAN = "FR"
PRO_PLAN = "PR"
@@ -97,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):
@@ -108,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.SET_NULL,
+ blank=True,
+ null=True,
+ related_name="sso_providers_created",
+ )
+ updated_at = models.DateTimeField(auto_now=True)
+ updated_by = models.ForeignKey(
+ "OrganisationMember",
+ on_delete=models.SET_NULL,
+ 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/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):
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
)
diff --git a/backend/api/templates/api/email_verification.html b/backend/api/templates/api/email_verification.html
new file mode 100644
index 000000000..9ee9920aa
--- /dev/null
+++ b/backend/api/templates/api/email_verification.html
@@ -0,0 +1,160 @@
+{% extends 'base.html' %} {% block content %}
+
+
+
+
+
+
+
+
+
+
+ Verify your email address
+
+
+
+
+
+
+ Click the button below to verify your email address and activate your Phase account.
+ This link will expire in 24 hours.
+
+
+
+
+
+
+
+
+
+
+
+ If you did not create this account, you can safely ignore this email.
+
+
+
+
+
+
+ Cheers
+ The Phase Team
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/backend/api/urls.py b/backend/api/urls.py
deleted file mode 100644
index dbd092736..000000000
--- a/backend/api/urls.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from django.urls import path, include
-from .views.auth import (
- AuthentikLoginView,
- AutheliaLoginView,
- GitHubEnterpriseLoginView,
- GoogleLoginView,
- GitHubLoginView,
- GitLabLoginView,
- OIDCLoginView,
- JumpCloudLoginView,
- EntraIDLoginView,
- OktaLoginView,
-)
-
-urlpatterns = [
- path("google/", GoogleLoginView.as_view(), name="google"),
- path("github/", GitHubLoginView.as_view(), name="github"),
- path(
- "github-enterprise/",
- GitHubEnterpriseLoginView.as_view(),
- name="github-enterprise",
- ),
- path("gitlab/", GitLabLoginView.as_view(), name="gitlab"),
- path("google-oidc/", OIDCLoginView.as_view(), name="google-oidc"),
- path("jumpcloud-oidc/", JumpCloudLoginView.as_view(), name="jumpcloud-oidc"),
- path("entra-id-oidc/", EntraIDLoginView.as_view(), name="microsoft"),
- path("authentik/", AuthentikLoginView.as_view(), name="authentik"),
- path("authelia/", AutheliaLoginView.as_view(), name="authelia"),
- path("okta-oidc/", OktaLoginView.as_view(), name="okta-oidc"),
-]
diff --git a/backend/api/utils/access/org_resolution.py b/backend/api/utils/access/org_resolution.py
new file mode 100644
index 000000000..94e35ff30
--- /dev/null
+++ b/backend/api/utils/access/org_resolution.py
@@ -0,0 +1,215 @@
+"""Resolve an Organisation from a GraphQL resolver kwarg.
+
+Auto-discovers the FK path from each Django model to Organisation via
+BFS so the SSO-enforcement middleware doesn't need a hand-maintained
+dispatch table — forgetting to register a new resolver otherwise
+becomes a silent enforcement bypass.
+
+Three cache layers: per-request dict (L1), Django cache / Redis (L2),
+and the DB as fallback.
+"""
+
+from collections import deque
+from functools import lru_cache
+
+from django.apps import apps
+from django.core.cache import cache
+
+
+# Hand-maintained: kwargs whose model name doesn't match `_id`.
+KWARG_MODEL_ALIASES = {
+ "env_id": "Environment",
+ "environment_id": "Environment",
+ "member_id": "OrganisationMember",
+ "invite_id": "OrganisationMemberInvite",
+ "provider_id": "OrganisationSSOProvider",
+ "account_id": "ServiceAccount",
+ "sa_id": "ServiceAccount",
+ "lease_id": "DynamicSecretLease",
+ "policy_id": "NetworkAccessPolicy",
+ "credential_id": "ProviderCredentials",
+ "sync_id": "EnvironmentSync",
+}
+
+# Handled by the middleware's dedicated probe (multiple backing models).
+AMBIGUOUS_KWARGS = frozenset({"token_id"})
+
+_CACHE_TTL = 3600
+_CACHE_NEGATIVE = "" # sentinel: looked up, no match
+
+
+def _redis_key(kind: str, id_value) -> str:
+ return f"org_for:{kind}:{id_value}"
+
+
+def _snake_to_pascal(name: str) -> str:
+ return "".join(part.title() for part in name.split("_"))
+
+
+@lru_cache(maxsize=None)
+def _path_to_organisation(model_name: str):
+ try:
+ Model = apps.get_model("api", model_name)
+ except LookupError:
+ return None
+ Organisation = apps.get_model("api", "Organisation")
+
+ if Model is Organisation:
+ return "id"
+
+ queue = deque([(Model, [])])
+ visited = {Model}
+ while queue:
+ cls, path = queue.popleft()
+ for field in cls._meta.get_fields():
+ if not (field.is_relation and field.many_to_one and not field.auto_created):
+ continue
+ related = field.related_model
+ if related is None:
+ continue
+ new_path = path + [field.name]
+ if related is Organisation:
+ return "__".join(new_path + ["id"])
+ if related not in visited:
+ visited.add(related)
+ queue.append((related, new_path))
+ return None
+
+
+def _resolve_from_db(kwarg_name: str, id_value) -> str | None:
+ if kwarg_name in ("org_id", "organisation_id"):
+ return str(id_value)
+ if not kwarg_name.endswith("_id"):
+ return None
+
+ model_name = KWARG_MODEL_ALIASES.get(kwarg_name) or _snake_to_pascal(
+ kwarg_name[:-3]
+ )
+ return _resolve_via_model(model_name, id_value)
+
+
+def _resolve_via_model(model_name: str, id_value) -> str | None:
+ """Walk `model_name.pk == id_value` to its Organisation FK."""
+ path = _path_to_organisation(model_name)
+ if not path:
+ return None
+
+ try:
+ Model = apps.get_model("api", model_name)
+ except LookupError:
+ return None
+
+ try:
+ org_id = (
+ Model.objects.filter(pk=id_value).values_list(path, flat=True).first()
+ )
+ except Exception:
+ return None
+ return str(org_id) if org_id else None
+
+
+def resolve_via_model(model_name: str, id_value, request_cache: dict):
+ """Like `resolve_org_id` but model is known by the caller (e.g. the
+ middleware's bare-`id` dispatch). Same three-tier cache."""
+ if not id_value:
+ return None
+
+ l1_key = (f"__model__:{model_name}", id_value)
+ if l1_key in request_cache:
+ return request_cache[l1_key]
+
+ kind = _model_name_to_default_kwarg_kind(model_name)
+ redis_key = _redis_key(kind, id_value)
+ try:
+ cached = cache.get(redis_key)
+ except Exception:
+ cached = None
+ if cached is not None:
+ result = cached or None
+ request_cache[l1_key] = result
+ return result
+
+ result = _resolve_via_model(model_name, id_value)
+
+ try:
+ cache.set(redis_key, result or _CACHE_NEGATIVE, timeout=_CACHE_TTL)
+ except Exception:
+ pass
+ request_cache[l1_key] = result
+ return result
+
+
+def resolve_org_id(kwarg_name: str, id_value, request_cache: dict):
+ if not id_value or kwarg_name in AMBIGUOUS_KWARGS:
+ return None
+ if kwarg_name in ("org_id", "organisation_id"):
+ return str(id_value)
+ if not kwarg_name.endswith("_id"):
+ return None
+
+ l1_key = (kwarg_name, id_value)
+ if l1_key in request_cache:
+ return request_cache[l1_key]
+
+ kind = KWARG_MODEL_ALIASES.get(kwarg_name, kwarg_name[:-3])
+ redis_key = _redis_key(kind, id_value)
+ try:
+ cached = cache.get(redis_key)
+ except Exception:
+ cached = None
+ if cached is not None:
+ result = cached or None
+ request_cache[l1_key] = result
+ return result
+
+ result = _resolve_from_db(kwarg_name, id_value)
+
+ try:
+ cache.set(redis_key, result or _CACHE_NEGATIVE, timeout=_CACHE_TTL)
+ except Exception:
+ pass
+ request_cache[l1_key] = result
+ return result
+
+
+def invalidate_org_for(model_class, pk) -> None:
+ model_name = model_class.__name__
+ kinds = {_model_name_to_default_kwarg_kind(model_name)}
+ for kwarg, aliased in KWARG_MODEL_ALIASES.items():
+ if aliased == model_name:
+ kinds.add(kwarg[:-3])
+ for kind in kinds:
+ try:
+ cache.delete(_redis_key(kind, pk))
+ except Exception:
+ pass
+
+
+def _model_name_to_default_kwarg_kind(model_name: str) -> str:
+ out = []
+ for i, ch in enumerate(model_name):
+ if ch.isupper() and i > 0:
+ out.append("_")
+ out.append(ch.lower())
+ return "".join(out)
+
+
+def register_invalidation_signals() -> None:
+ """Wire post_delete on every org-scoped model. Soft-delete doesn't
+ invalidate because the org mapping remains valid for the soft-deleted
+ row; only hard deletes need to clear the cache."""
+ from django.db.models.signals import post_delete
+
+ Organisation = apps.get_model("api", "Organisation")
+ for model in apps.get_models():
+ if model._meta.app_label != "api":
+ continue
+ if model is Organisation:
+ continue
+ if _path_to_organisation(model.__name__) is None:
+ continue
+
+ def _handler(sender, instance, **_kwargs):
+ invalidate_org_for(sender, instance.pk)
+
+ post_delete.connect(_handler, sender=model, weak=False)
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
diff --git a/backend/api/views/auth.py b/backend/api/views/auth.py
index 221033ae6..7054f6bd6 100644
--- a/backend/api/views/auth.py
+++ b/backend/api/views/auth.py
@@ -6,8 +6,6 @@
from django.views.decorators.http import require_POST
from api.utils.syncing.auth import store_oauth_token
-from api.authentication.providers.authentik.views import AuthentikOpenIDConnectAdapter
-from api.authentication.providers.authelia.views import AutheliaOpenIDConnectAdapter
from backend.utils.secrets import get_secret
from api.serializers import (
ServiceAccountTokenSerializer,
@@ -22,113 +20,11 @@
from django.conf import settings
from django.contrib.auth import logout
from django.http import JsonResponse
-from django.http import JsonResponse
-from api.authentication.adapters.gitlab import CustomGitLabOAuth2Adapter
-from api.authentication.adapters.google import CustomGoogleOAuth2Adapter
-from api.authentication.adapters.github import CustomGitHubOAuth2Adapter
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from api.throttling import PlanBasedRateThrottle
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
-from dj_rest_auth.registration.views import SocialLoginView
-from allauth.socialaccount.providers.oauth2.client import OAuth2Client
-from django.conf import settings
-from ee.authentication.sso.oidc.util.google.views import GoogleOpenIDConnectAdapter
-from ee.authentication.sso.oidc.util.jumpcloud.views import (
- JumpCloudOpenIDConnectAdapter,
-)
-from ee.authentication.sso.oidc.entraid.views import CustomMicrosoftGraphOAuth2Adapter
-from ee.authentication.sso.oauth.github_enterprise.views import (
- GitHubEnterpriseOAuth2Adapter,
-)
-from ee.authentication.sso.oidc.okta.views import (
- OktaOpenIDConnectAdapter,
-)
-
-CLOUD_HOSTED = settings.APP_HOST == "cloud"
-
-
-class GoogleLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = CustomGoogleOAuth2Adapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class GitHubLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = CustomGitHubOAuth2Adapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class GitHubEnterpriseLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = GitHubEnterpriseOAuth2Adapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class GitLabLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = CustomGitLabOAuth2Adapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class OIDCLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = GoogleOpenIDConnectAdapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class JumpCloudLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = JumpCloudOpenIDConnectAdapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class EntraIDLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = CustomMicrosoftGraphOAuth2Adapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
- def get_adapter(self, request):
- """
- Initialize the adapter with the request
- """
- adapter = self.adapter_class(request=request)
- return adapter
-
- def post(self, request, *args, **kwargs):
- """Override to ensure adapter initialization is correct"""
- self.request = request
- return super().post(request, *args, **kwargs)
-
-
-class AuthentikLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = AuthentikOpenIDConnectAdapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class AutheliaLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = AutheliaOpenIDConnectAdapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
-
-
-class OktaLoginView(SocialLoginView):
- authentication_classes = []
- adapter_class = OktaOpenIDConnectAdapter
- callback_url = settings.OAUTH_REDIRECT_URI
- client_class = OAuth2Client
@require_POST
diff --git a/backend/api/views/auth_password.py b/backend/api/views/auth_password.py
new file mode 100644
index 000000000..2a6fcb431
--- /dev/null
+++ b/backend/api/views/auth_password.py
@@ -0,0 +1,567 @@
+import json
+import logging
+import os
+import re
+import secrets
+from datetime import timedelta
+
+from django.conf import settings
+from django.contrib.auth import login, get_user_model
+from django.db import transaction
+from django.http import JsonResponse
+from django.shortcuts import redirect
+from django.utils import timezone
+from django.views.decorators.csrf import csrf_exempt
+
+from rest_framework.decorators import (
+ api_view,
+ authentication_classes,
+ permission_classes,
+ throttle_classes,
+)
+from rest_framework.permissions import AllowAny
+from rest_framework.throttling import AnonRateThrottle
+
+from api.views.sso import _check_email_domain_allowed
+
+
+from django.db.models import Q
+
+from api.models import (
+ EmailVerification,
+ OrganisationMember, # noqa: F401 — kept for backward compat with test patches
+ OrganisationMemberInvite,
+ OrganisationSSOProvider,
+)
+
+logger = logging.getLogger(__name__)
+
+FRONTEND_URL = os.getenv("ALLOWED_ORIGINS", "").split(",")[0].strip()
+
+
+# --- Rate Limiting ---
+
+
+class PasswordRegisterThrottle(AnonRateThrottle):
+ rate = "5/min"
+
+
+class AuthLoginThrottle(AnonRateThrottle):
+ rate = "10/min"
+
+
+class EmailCheckThrottle(AnonRateThrottle):
+ rate = "20/min"
+
+
+class ResendVerificationThrottle(AnonRateThrottle):
+ rate = "3/min"
+
+
+# --- Helpers ---
+
+
+def username_for_email(email: str) -> str:
+ """Produce a username fitting CustomUser.username (varchar 64). Emails
+ ≤64 chars pass through verbatim; longer ones get a hash-derived
+ synthetic. Email column (varchar 100) always stores the full address."""
+ import hashlib
+
+ if len(email) <= 64:
+ return email
+ digest = hashlib.sha256(email.encode("utf-8")).hexdigest()[:32]
+ return f"u_{digest}"
+
+
+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")
+
+
+def _smtp_configured():
+ """Check if SMTP email sending is configured."""
+ return bool(getattr(settings, "EMAIL_HOST", ""))
+
+
+def _signups_allowed():
+ """Self-service signup is on by default. Operators on closed instances
+ can set ALLOW_SIGNUPS=false to require an invite for any new account
+ (password or SSO). Existing user sign-in is never affected.
+
+ Fail-open: any value other than an explicit falsy keeps signups open,
+ so a typo can't accidentally lock down an instance."""
+ return os.getenv("ALLOW_SIGNUPS", "").lower() not in ("false", "0", "no")
+
+
+def _has_pending_invite(email):
+ """Whether the email matches a pending OrganisationMemberInvite. New
+ account creation under ALLOW_SIGNUPS=false is gated on (signups_allowed
+ OR pending invite) — invitees are always allowed in."""
+ return OrganisationMemberInvite.objects.filter(
+ invitee_email__iexact=email,
+ valid=True,
+ expires_at__gt=timezone.now(),
+ ).exists()
+
+
+_SIGNUPS_DISABLED_MSG = (
+ "Sign-ups are disabled on this instance. "
+ "Ask an administrator to invite you."
+)
+
+
+_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 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 _INVITE_PATH_RE.match(value):
+ return None
+ return value
+
+
+def _send_verification_email(email, verify_url):
+ """Send verification email if SMTP is configured. Always log."""
+ logger.info(
+ json.dumps(
+ {
+ "event": "email_verification",
+ "email": email,
+ "url": verify_url,
+ }
+ )
+ )
+
+ if _smtp_configured():
+ from api.emails import send_email
+
+ send_email(
+ subject="Verify your Phase account",
+ recipient_list=[email],
+ template_name="api/email_verification.html",
+ context={"verify_url": verify_url},
+ )
+
+
+# --- Endpoints ---
+
+
+@csrf_exempt
+@api_view(["POST"])
+@authentication_classes([])
+@permission_classes([AllowAny])
+@throttle_classes([PasswordRegisterThrottle])
+def password_register(request):
+ """Register a new user with email/password.
+
+ Creates the user account and sends a verification email.
+ Organisation creation happens separately after email verification
+ via the onboarding flow (CreateOrganisationMutation).
+ """
+ User = get_user_model()
+
+ data = request.data
+ email = (data.get("email") or "").lower().strip()
+ auth_hash = data.get("authHash", "")
+ full_name = (data.get("fullName") or "").strip()
+
+ if not email or not auth_hash:
+ return JsonResponse({"error": "Email and password are required."}, status=400)
+
+ # Basic email format check
+ if "@" not in email or "." not in email.split("@")[-1]:
+ 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,
+ )
+
+ if User.objects.filter(email=email).exists():
+ return JsonResponse(
+ {"error": "An account with this email already exists."}, status=409
+ )
+
+ # Self-service sign-up gate. Invitees are always allowed through —
+ # closed instances still need to be able to onboard people, and the
+ # invite link is the operator's affirmative consent for that email.
+ if not _signups_allowed() and not _has_pending_invite(email):
+ return JsonResponse({"error": _SIGNUPS_DISABLED_MSG}, status=403)
+
+ # 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()
+
+ with transaction.atomic():
+ user = User.objects.create_user(
+ username=username_for_email(email),
+ email=email,
+ password=auth_hash,
+ )
+ user.active = skip_verification
+ if full_name:
+ user.full_name = full_name
+ user.save()
+
+ if not skip_verification:
+ token = secrets.token_urlsafe(32)
+ EmailVerification.objects.create(
+ user=user,
+ token=token,
+ expires_at=timezone.now() + timedelta(hours=24),
+ )
+
+ if skip_verification:
+ 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.
+ backend_url = os.getenv("NEXT_PUBLIC_BACKEND_API_BASE", "").rstrip("/")
+ verify_url = f"{backend_url}/auth/verify-email/{token}/"
+ # Forward the post-login destination (e.g. /invite/) through the
+ # verification link so invite-flow signups land back at acceptance after
+ # verifying their email.
+ 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)
+ except Exception:
+ logger.exception("Failed to send verification email to %s", email)
+
+ return JsonResponse({"message": "Verification required."}, status=201)
+
+
+@api_view(["GET"])
+@authentication_classes([])
+@permission_classes([AllowAny])
+def verify_email(request, token):
+ """Verify email address and activate user account."""
+ from urllib.parse import urlencode
+
+ next_url = _safe_internal_path(request.GET.get("next"))
+
+ def _login_redirect(**params):
+ if next_url:
+ params["callbackUrl"] = next_url
+ qs = urlencode(params)
+ return redirect(f"{FRONTEND_URL}/login?{qs}")
+
+ try:
+ ev = EmailVerification.objects.select_related("user").get(token=token)
+ except EmailVerification.DoesNotExist:
+ return _login_redirect(error="invalid_verification_token")
+
+ if ev.verified:
+ return _login_redirect(verified="true")
+
+ if ev.expires_at < timezone.now():
+ return _login_redirect(error="verification_expired")
+
+ ev.verified = True
+ ev.save()
+
+ user = ev.user
+ user.active = True
+ user.save()
+
+ return _login_redirect(verified="true")
+
+
+@csrf_exempt
+@api_view(["POST"])
+@authentication_classes([])
+@permission_classes([AllowAny])
+@throttle_classes([ResendVerificationThrottle])
+def resend_verification(request):
+ """Resend verification email with a fresh token."""
+ User = get_user_model()
+
+ email = (request.data.get("email") or "").lower().strip()
+ if not email:
+ return JsonResponse({"error": "Email is required."}, status=400)
+
+ try:
+ 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."
+ }
+ )
+
+ if user.active:
+ 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()
+ token = secrets.token_urlsafe(32)
+ EmailVerification.objects.create(
+ user=user,
+ token=token,
+ expires_at=timezone.now() + timedelta(hours=24),
+ )
+
+ backend_url = os.getenv("NEXT_PUBLIC_BACKEND_API_BASE", "").rstrip("/")
+ 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."
+ }
+ )
+
+
+@csrf_exempt
+@api_view(["POST"])
+@authentication_classes([])
+@permission_classes([AllowAny])
+@throttle_classes([AuthLoginThrottle])
+def password_login(request):
+ """Authenticate with email + authHash, create a Django session."""
+ User = get_user_model()
+
+ data = request.data
+ email = (data.get("email") or "").lower().strip()
+ auth_hash = data.get("authHash", "")
+
+ if not email or not auth_hash:
+ return JsonResponse({"error": "Email and password are required."}, status=400)
+
+ if not _check_email_domain_allowed(email):
+ return JsonResponse({"error": "Invalid email or password."}, status=401)
+
+ try:
+ user = User.objects.get(email=email)
+ except User.DoesNotExist:
+ return JsonResponse({"error": "Invalid email or password."}, status=401)
+
+ # 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)
+ request.session.pop("auth_sso_provider_id", None)
+
+ 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")
+ or extra.get("picture")
+ or extra.get("photo")
+ or extra.get("avatar")
+ )
+ full_name = extra.get("name", "")
+ 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),
+ "email": user.email,
+ "fullName": full_name or user.email,
+ "avatarUrl": avatar_url,
+ "authMethod": user.auth_method,
+ }
+ )
+
+
+@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([])
+@permission_classes([AllowAny])
+@throttle_classes([EmailCheckThrottle])
+def email_check(request):
+ """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()
+
+ 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)
+
+ # 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:
+ user = None
+ has_password = True
+
+ # 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(
+ id=invite_id,
+ valid=True,
+ expires_at__gt=timezone.now(),
+ invitee_email__iexact=email,
+ )
+ invite_org_filter = Q(organisation=invite.organisation)
+ except OrganisationMemberInvite.DoesNotExist:
+ pass
+
+ 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()
+ )
+
+ 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/api/views/sso.py b/backend/api/views/sso.py
new file mode 100644
index 000000000..d1df2873d
--- /dev/null
+++ b/backend/api/views/sso.py
@@ -0,0 +1,964 @@
+import os
+import time
+import base64
+import secrets
+import logging
+import threading
+import requests as http_requests
+from urllib.parse import urlencode, urlparse, 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
+from api.models import OrganisationSSOProvider
+from api.utils.network import validate_url_is_safe
+from django.core.exceptions import ValidationError
+
+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 _safe_oidc_request(method, url, **kwargs):
+ """Wrapper around requests.{get,post} that blocks SSRF to private
+ IPs when running on cloud. Redirects are disabled so a 30x response
+ can't pivot the fetch to an internal service after validation.
+
+ Self-hosted deployments bypass the IP allowlist — customers may
+ legitimately point at internal OIDC servers — but redirects are
+ still disabled for consistency.
+
+ DNS TOCTOU (rebinding between validate_url_is_safe's resolution and
+ requests' resolution) is a known limitation; mitigating it would
+ require a custom transport that pins the resolved IP.
+ """
+ if settings.APP_HOST == "cloud":
+ try:
+ validate_url_is_safe(url)
+ except ValidationError:
+ raise ValueError(f"URL rejected by safety check: {url}")
+ kwargs.setdefault("allow_redirects", False)
+ return http_requests.request(method, url, **kwargs)
+
+
+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 = _safe_oidc_request("GET", discovery_url, timeout=10)
+ resp.raise_for_status()
+ config = resp.json()
+ authorize_url = config["authorization_endpoint"]
+ token_url = config["token_endpoint"]
+ # Validate endpoints returned by discovery before trusting them —
+ # a hostile discovery doc could otherwise point token_endpoint at
+ # an internal service to exfil client_secret.
+ if settings.APP_HOST == "cloud":
+ try:
+ validate_url_is_safe(token_url)
+ except ValidationError:
+ raise ValueError(
+ f"Discovery returned unsafe token_endpoint: {token_url}"
+ )
+ endpoints = {
+ "authorize_url": authorize_url,
+ "token_url": token_url,
+ }
+ 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, *, org_config_id=None):
+ """Get or create a persisted SocialApp so that SocialToken ForeignKeys work.
+
+ Instance-level SSO has exactly one SocialApp per provider_id.
+
+ Org-level SSO requires disambiguation: two orgs configuring the same
+ provider type (both Okta) share provider_id="okta-oidc" but each
+ registers a distinct client_id with its IdP. Keying solely on
+ provider would cause Org B's save to clobber Org A's credentials.
+ We key org-level rows on (provider, client_id), which is unique per
+ IdP registration.
+ """
+ provider = config["provider_id"]
+ client_id = config["client_id"]
+ client_secret = config["client_secret"]
+
+ if org_config_id:
+ app = SocialApp.objects.filter(
+ provider=provider, client_id=client_id
+ ).first()
+ if app is None:
+ # Concurrent first-logins on the same org-config could both
+ # miss this lookup and race to create() — the duplicate row
+ # is harmless, subsequent logins will pick whichever exists.
+ #
+ # `name` is varchar(40) in allauth's schema. Provider strings
+ # like "jumpcloud-oidc" (14) plus a UUID (36) overflow it;
+ # truncate to fit. Real disambiguation is (provider,
+ # client_id), already enforced by the lookup above.
+ app = SocialApp.objects.create(
+ provider=provider,
+ name=f"{provider}:{org_config_id}"[:40],
+ client_id=client_id,
+ secret=client_secret,
+ )
+ return app
+ if app.secret != client_secret:
+ app.secret = client_secret
+ app.save(update_fields=["secret"])
+ return app
+
+ # Instance-level: one row per provider_id.
+ app, created = SocialApp.objects.get_or_create(
+ provider=provider,
+ defaults={
+ "name": provider,
+ "client_id": client_id,
+ "secret": client_secret,
+ },
+ )
+ if not created:
+ changed = False
+ if app.client_id != client_id:
+ app.client_id = client_id
+ changed = True
+ if app.secret != client_secret:
+ app.secret = client_secret
+ changed = True
+ if changed:
+ app.save()
+ return app
+
+
+def _complete_login_bypassing_allauth(request, social_login, token, *, org_config_id=None):
+ """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.
+
+ Security:
+ - Org-level SSO (org_config_id set): the IdP is controlled by
+ the org's admin, NOT a universally trusted provider. We pin
+ trust to org membership — the claimed email must match an
+ existing OrganisationMember of this org, or a pending invite.
+ Otherwise a malicious admin could issue tokens claiming any
+ email and hijack existing Phase accounts.
+ - Instance-level SSO: trust the email_verified claim when the
+ IdP exposes it. An explicit False is grounds for rejection.
+ """
+ from django.utils import timezone
+ from api.models import OrganisationMember, OrganisationMemberInvite
+
+ 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()
+
+ if org_config_id:
+ # Anchor trust to org state — the only emails we allow through
+ # an org-configured IdP are those the org itself has already
+ # authorised (members or pending invites).
+ try:
+ org_provider = OrganisationSSOProvider.objects.select_related(
+ "organisation"
+ ).get(id=org_config_id)
+ except OrganisationSSOProvider.DoesNotExist:
+ raise ValueError("SSO provider no longer exists")
+
+ org = org_provider.organisation
+ has_membership = OrganisationMember.objects.filter(
+ user__email=email,
+ organisation=org,
+ deleted_at__isnull=True,
+ ).exists()
+ has_invite = OrganisationMemberInvite.objects.filter(
+ invitee_email__iexact=email,
+ organisation=org,
+ valid=True,
+ expires_at__gt=timezone.now(),
+ ).exists()
+ if not has_membership and not has_invite:
+ logger.warning(
+ f"Blocked org SSO login: {email} not a member of or "
+ f"invited to {org.name}"
+ )
+ raise ValueError(
+ "This email is not authorised for this organisation."
+ )
+ else:
+ # Instance-level: only reject on explicit False. Providers that
+ # don't emit the claim (Microsoft work accounts, older OIDC) are
+ # handled by the adapter-level trust of the IdP itself.
+ if extra_data.get("email_verified") is False:
+ logger.warning(
+ f"Blocked instance SSO login: {email} not verified by IdP"
+ )
+ raise ValueError("Email not verified by identity provider.")
+
+ # Resolve the Django user. Look up by (provider, uid) FIRST — this IdP
+ # identity may already be linked to a user whose email on the IdP side
+ # has since changed. If we used the current email to resolve, we would
+ # create a fresh CustomUser and orphan the existing one, taking every
+ # OrganisationMember with it. Only fall back to email lookup (or user
+ # creation) for IdP identities we've never seen before.
+ provider = social_login.account.provider
+ uid = social_login.account.uid
+ try:
+ sa = SocialAccount.objects.get(provider=provider, uid=uid)
+ user = sa.user
+ sa.extra_data = extra_data
+ sa.save(update_fields=["extra_data"])
+ except SocialAccount.DoesNotExist:
+ # New (provider, uid). Refuse to silently bind it to an existing
+ # email — the membership/invite gate above doesn't prove the IdP
+ # speaks for the email (a malicious org admin can invite anyone).
+ # Linking must be opt-in from an authenticated session.
+ try:
+ existing_user = User.objects.get(email=email)
+ except User.DoesNotExist:
+ existing_user = None
+
+ if existing_user is not None:
+ already_linked = SocialAccount.objects.filter(
+ user=existing_user, provider=provider
+ ).exists()
+ if not already_linked:
+ logger.warning(
+ f"Refused silent SSO link: provider={provider} "
+ f"email={email} already has an account."
+ )
+ raise ValueError(
+ "An account with this email already exists. "
+ "Sign in with your existing method, then link "
+ "this provider from your account settings."
+ )
+ # Same provider linked under a different uid — refuse the
+ # silent re-link so the user can clean up deliberately.
+ logger.warning(
+ f"Refused SSO link: provider={provider} email={email} "
+ f"already linked under a different uid."
+ )
+ raise ValueError(
+ "This sign-in identity does not match the one on "
+ "file for this account. Contact your administrator."
+ )
+
+ from api.views.auth_password import (
+ _has_pending_invite,
+ _signups_allowed,
+ username_for_email,
+ )
+
+ # Self-service sign-up gate. Org-level SSO already passed the
+ # membership-or-invite check above, so this only bites instance-level
+ # SSO arriving from an unconfigured stranger. Invitees pass through.
+ if (
+ org_config_id is None
+ and not _signups_allowed()
+ and not _has_pending_invite(email)
+ ):
+ logger.warning(
+ f"Blocked SSO signup for {email}: ALLOW_SIGNUPS=false "
+ f"and no pending invite"
+ )
+ raise ValueError(
+ "Sign-ups are disabled on this instance. "
+ "Ask an administrator to invite you."
+ )
+
+ user = User.objects.create_user(
+ username=username_for_email(email),
+ email=email,
+ password=None,
+ )
+ sa = SocialAccount.objects.create(
+ provider=provider,
+ uid=uid,
+ 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 = _safe_oidc_request("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
+
+ # 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": 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):
+ """
+ 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,
+ )
+
+ # 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)
+
+ 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("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": 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
+
+ # Validate that the authorize URL is from a trusted origin before redirecting.
+ # For non-OIDC providers this comes from the static registry; for OIDC providers
+ # it comes from the discovery document fetched from the configured issuer.
+ 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 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:
+ # Redirect to a fixed URL with a safe error parameter.
+ # error_desc is from the IdP — quote it to prevent injection.
+ error_desc = request.GET.get("error_description", error)
+ login_url = f"{FRONTEND_URL}/login"
+ return redirect(f"{login_url}?error={quote(error_desc, safe='')}")
+
+ 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")
+
+ # 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]
+
+ 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.
+ # For org-level SSO, scope the SocialApp to the config_id so
+ # multiple orgs configuring the same provider_id don't
+ # overwrite each other's credentials.
+ app = _get_or_create_social_app(config, org_config_id=org_config_id)
+
+ 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).
+ try:
+ _complete_login_bypassing_allauth(
+ request, social_login, token, org_config_id=org_config_id
+ )
+ except ValueError as e:
+ logger.warning(f"SSO login rejected: {e}")
+ return redirect(
+ f"{FRONTEND_URL}/login?error={quote(str(e), safe='')}"
+ )
+
+ 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")
+
+ # 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", "sso_org_config_id"]:
+ request.session.pop(key, None)
+
+ if return_to and return_to.startswith("/") and not return_to.startswith("//"):
+ return redirect(FRONTEND_URL + return_to)
+
+ # Org-level SSO with no deep link: route the user to the
+ # invite-acceptance wizard if they have a pending invite to
+ # this org and aren't yet a member. The email check earlier
+ # gated entry on (membership OR invite); membership doesn't
+ # exist yet on a brand-new user, and invite acceptance must
+ # run client-side (mnemonic-derived keyring, deviceKey
+ # wrap), so we hand off to /invite/ rather than
+ # stranding the user at /onboard.
+ if org_config_id:
+ from api.models import OrganisationMember, OrganisationMemberInvite
+ from django.utils import timezone as _tz
+ from api.utils.rest import encode_string_to_base64
+
+ org_id = request.session.get("auth_sso_org_id")
+ if org_id:
+ has_membership = OrganisationMember.objects.filter(
+ user=request.user,
+ organisation_id=org_id,
+ deleted_at__isnull=True,
+ ).exists()
+ if not has_membership:
+ pending_invite = OrganisationMemberInvite.objects.filter(
+ invitee_email__iexact=user_email,
+ organisation_id=org_id,
+ valid=True,
+ expires_at__gt=_tz.now(),
+ ).first()
+ if pending_invite is not None:
+ invite_b64 = encode_string_to_base64(
+ str(pending_invite.id)
+ )
+ return redirect(
+ f"{FRONTEND_URL}/invite/{invite_b64}"
+ )
+
+ 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/graphene/middleware.py b/backend/backend/graphene/middleware.py
index 231b02911..efc9207cb 100644
--- a/backend/backend/graphene/middleware.py
+++ b/backend/backend/graphene/middleware.py
@@ -1,9 +1,61 @@
from graphql import GraphQLResolveInfo
from graphql import GraphQLError
+from graphql.type import GraphQLList, GraphQLNonNull, GraphQLObjectType
from api.models import NetworkAccessPolicy, Organisation, OrganisationMember
from itertools import chain
+from django.core.cache import cache
+
from api.utils.access.ip import get_client_ip
+from api.utils.access.org_resolution import resolve_org_id, resolve_via_model
+
+
+def _output_graphene_type(info: GraphQLResolveInfo):
+ """Unwrap NonNull/List wrappers from the mutation's return type and
+ return the underlying Graphene class (or None)."""
+ return_type = info.return_type
+ while isinstance(return_type, (GraphQLNonNull, GraphQLList)):
+ return_type = return_type.of_type
+ if not isinstance(return_type, GraphQLObjectType):
+ return None
+ return getattr(return_type, "graphene_type", None)
+
+
+def _bypasses_sso_enforcement(info: GraphQLResolveInfo) -> bool:
+ """SSO admin mutations carry `bypass_sso_enforcement = True` so an
+ admin locked out by their own require_sso=True config can recover.
+ Per-mutation Owner/Admin role gating remains the safety net."""
+ graphene_type = _output_graphene_type(info)
+ return bool(getattr(graphene_type, "bypass_sso_enforcement", False))
+
+
+def _model_for_mutation(info: GraphQLResolveInfo):
+ """Derive the Django model a mutation operates on for the bare-`id`/
+ `ids` case. Reads `org_resource_model` if declared, else picks the
+ first `DjangoObjectType`-backed field on the mutation's output."""
+ graphene_type = _output_graphene_type(info)
+ if graphene_type is None:
+ return None
+
+ explicit = getattr(graphene_type, "org_resource_model", None)
+ if explicit:
+ return explicit
+
+ return_type = info.return_type
+ while isinstance(return_type, (GraphQLNonNull, GraphQLList)):
+ return_type = return_type.of_type
+ for _name, gql_field in return_type.fields.items():
+ ftype = gql_field.type
+ while isinstance(ftype, (GraphQLNonNull, GraphQLList)):
+ ftype = ftype.of_type
+ if not isinstance(ftype, GraphQLObjectType):
+ continue
+ gtype = getattr(ftype, "graphene_type", None)
+ meta = getattr(gtype, "_meta", None) if gtype is not None else None
+ model = getattr(meta, "model", None) if meta is not None else None
+ if model is not None:
+ return model.__name__
+ return None
class IPRestrictedError(GraphQLError):
@@ -17,6 +69,263 @@ 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:
+ """Enforce per-org SSO requirements on every org-scoped resolver.
+
+ Sessions established via the org-level SSO flow carry
+ ``auth_sso_org_id``; sessions from instance-level SSO (Google,
+ GitHub, GitLab) don't, so they can't bypass org-level enforcement.
+ """
+
+ _DECISION_CACHE_ATTR = "_org_sso_decision_cache"
+ _ID_CACHE_ATTR = "_org_sso_id_cache"
+ _DECISION_REDIS_TTL = 60
+
+ @staticmethod
+ def _decision_redis_key(org_id) -> str:
+ return f"org_sso_decision:{org_id}"
+
+ @classmethod
+ def invalidate_decision(cls, org_id) -> None:
+ """Called when ``require_sso`` flips so the change takes effect
+ before the Redis TTL would naturally expire the cached value."""
+ try:
+ cache.delete(cls._decision_redis_key(org_id))
+ except Exception:
+ pass
+
+ 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)
+
+ # SSO admin mutations carry an opt-in bypass so a misconfigured
+ # require_sso=True org isn't a UI dead-end. Per-mutation role
+ # checks remain in force.
+ if _bypasses_sso_enforcement(info):
+ return next(root, info, **kwargs)
+
+ org_id = self._resolve_org_id(request, kwargs, info)
+ if not org_id:
+ return next(root, info, **kwargs)
+
+ # Skip the require_sso lookup when the session is already SSO-bound
+ # to this org — it would have been blocked at sign-in otherwise.
+ session = getattr(request, "session", None)
+ session_method = session.get("auth_method") if session else None
+ session_org_id = session.get("auth_sso_org_id") if session else None
+ if session_method == "sso" and session_org_id == str(org_id):
+ return next(root, info, **kwargs)
+
+ decision = self._get_org_decision(request, org_id)
+ if decision is None:
+ return next(root, info, **kwargs)
+
+ blocked, org_name = decision
+ if not blocked:
+ return next(root, info, **kwargs)
+
+ raise SSORequiredError(org_name, str(org_id))
+
+ @classmethod
+ def _get_org_decision(cls, request, org_id):
+ cache_l1 = getattr(request, cls._DECISION_CACHE_ATTR, None)
+ if cache_l1 is None:
+ cache_l1 = {}
+ setattr(request, cls._DECISION_CACHE_ATTR, cache_l1)
+
+ key = str(org_id)
+ if key in cache_l1:
+ return cache_l1[key]
+
+ redis_key = cls._decision_redis_key(org_id)
+ try:
+ cached = cache.get(redis_key)
+ except Exception:
+ cached = None
+ if cached is not None:
+ # Sentinel: [False, ""] means "org not loadable"
+ if cached == [False, ""]:
+ cache_l1[key] = None
+ return None
+ decision = (bool(cached[0]), cached[1])
+ cache_l1[key] = decision
+ return decision
+
+ try:
+ org = Organisation.objects.only("require_sso", "name").get(id=org_id)
+ decision = (bool(org.require_sso), org.name)
+ except Organisation.DoesNotExist:
+ decision = None
+
+ try:
+ cache.set(
+ redis_key,
+ [decision[0], decision[1]] if decision else [False, ""],
+ timeout=cls._DECISION_REDIS_TTL,
+ )
+ except Exception:
+ pass
+
+ cache_l1[key] = decision
+ return decision
+
+ @classmethod
+ def _resolve_org_id(cls, request, kwargs, info=None):
+ request_cache = getattr(request, cls._ID_CACHE_ATTR, None)
+ if request_cache is None:
+ request_cache = {}
+ setattr(request, cls._ID_CACHE_ATTR, request_cache)
+
+ for direct in ("organisation_id", "org_id"):
+ val = kwargs.get(direct)
+ if val:
+ return str(val)
+
+ for name, value in kwargs.items():
+ if not value or not isinstance(name, str):
+ continue
+
+ # `_id` — FK auto-discovery in org_resolution.
+ if name.endswith("_id"):
+ org_id = resolve_org_id(name, value, request_cache)
+ if org_id:
+ return org_id
+ continue
+
+ # Bare `id` / `ids` — model derived from the mutation's
+ # return type or an `org_resource_model` class attribute.
+ if name in ("id", "ids"):
+ if info is None:
+ continue
+ model_name = _model_for_mutation(info)
+ if not model_name:
+ continue
+ pk = value[0] if name == "ids" and value else value
+ if pk and not isinstance(pk, (str, int)):
+ continue
+ org_id = resolve_via_model(model_name, pk, request_cache)
+ if org_id:
+ return org_id
+ continue
+
+ # `*_data` input objects — recurse into their `*_id` fields.
+ if name.endswith("_data"):
+ org_id = cls._resolve_from_input_value(value, request_cache)
+ if org_id:
+ return org_id
+
+ token_id = kwargs.get("token_id")
+ if token_id:
+ return cls._lookup_token_org(request, token_id)
+
+ return None
+
+ @classmethod
+ def _resolve_from_input_value(cls, value, request_cache):
+ """Walk an input object (or list of them) for any `_id`."""
+ items = value if isinstance(value, (list, tuple)) else [value]
+ for item in items:
+ if item is None:
+ continue
+ try:
+ entries = (
+ item.items()
+ if hasattr(item, "items")
+ else getattr(item, "__dict__", {}).items()
+ )
+ except Exception:
+ continue
+ for key, val in entries:
+ if not val or not isinstance(key, str):
+ continue
+ if key in ("organisation_id", "org_id"):
+ return str(val)
+ if key.endswith("_id"):
+ org_id = resolve_org_id(key, val, request_cache)
+ if org_id:
+ return org_id
+ return None
+
+ @classmethod
+ def _lookup_token_org(cls, request, token_id):
+ """token_id spans four models (UserToken / ServiceToken /
+ ServiceAccountToken / EnvironmentToken); probe in order, stop
+ on first hit. UUIDs are globally unique so collisions can't
+ happen."""
+ from api.models import (
+ EnvironmentToken,
+ ServiceAccountToken,
+ ServiceToken,
+ UserToken,
+ )
+ request_cache = getattr(request, cls._ID_CACHE_ATTR, {})
+ cache_key = ("token_id", token_id)
+ if cache_key in request_cache:
+ return request_cache[cache_key]
+
+ 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)
+ try:
+ member = OrganisationMember.objects.only("organisation_id").get(
+ id=ut.user_id, deleted_at__isnull=True
+ )
+ org_id = str(member.organisation_id)
+ except OrganisationMember.DoesNotExist:
+ pass
+ except UserToken.DoesNotExist:
+ pass
+
+ if not org_id:
+ try:
+ st = ServiceToken.objects.only("app_id").get(id=token_id)
+ org_id = resolve_org_id("app_id", st.app_id, request_cache)
+ except ServiceToken.DoesNotExist:
+ pass
+
+ if not org_id:
+ try:
+ sat = ServiceAccountToken.objects.only(
+ "service_account_id"
+ ).get(id=token_id)
+ org_id = resolve_org_id(
+ "service_account_id", sat.service_account_id, request_cache
+ )
+ except ServiceAccountToken.DoesNotExist:
+ pass
+
+ if not org_id:
+ try:
+ et = EnvironmentToken.objects.only("environment_id").get(id=token_id)
+ org_id = resolve_org_id(
+ "environment_id", et.environment_id, request_cache
+ )
+ except EnvironmentToken.DoesNotExist:
+ pass
+
+ request_cache[cache_key] = org_id
+ return org_id
+
+
class IPWhitelistMiddleware:
"""
Graphene middleware to enforce network access policy for human users
diff --git a/backend/backend/graphene/mutations/access.py b/backend/backend/graphene/mutations/access.py
index 4916df960..cbb59dc3d 100644
--- a/backend/backend/graphene/mutations/access.py
+++ b/backend/backend/graphene/mutations/access.py
@@ -94,6 +94,10 @@ def mutate(cls, root, info, id, name, description, color, permissions):
class DeleteCustomRoleMutation(graphene.Mutation):
+ # SSO middleware reads this to resolve org from the bare `id`
+ # kwarg (output is `ok`, no model implicit in return type).
+ org_resource_model = "Role"
+
class Arguments:
id = graphene.ID(required=True)
@@ -201,6 +205,10 @@ def mutate(cls, root, info, policy_inputs):
class DeleteNetworkAccessPolicyMutation(graphene.Mutation):
+ # SSO middleware reads this to resolve org from the bare `id`
+ # kwarg (output is `ok`, no model implicit in return type).
+ org_resource_model = "NetworkAccessPolicy"
+
class Arguments:
id = graphene.ID(required=True)
@@ -437,6 +445,10 @@ def mutate(
class DeleteIdentityMutation(graphene.Mutation):
+ # SSO middleware reads this to resolve org from the bare `id`
+ # kwarg (output is `ok`, no model implicit in return type).
+ org_resource_model = "Identity"
+
class Arguments:
id = graphene.ID(required=True)
diff --git a/backend/backend/graphene/mutations/app.py b/backend/backend/graphene/mutations/app.py
index d9eae6450..3b259951c 100644
--- a/backend/backend/graphene/mutations/app.py
+++ b/backend/backend/graphene/mutations/app.py
@@ -167,6 +167,10 @@ def mutate(cls, root, info, id, name=None, description=None):
class DeleteAppMutation(graphene.Mutation):
+ # SSO middleware reads this to resolve org from the bare `id`
+ # kwarg (output is `ok`, no model implicit in return type).
+ org_resource_model = "App"
+
class Arguments:
id = graphene.ID(required=True)
diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py
index 1a2077415..c01af8da3 100644
--- a/backend/backend/graphene/mutations/environment.py
+++ b/backend/backend/graphene/mutations/environment.py
@@ -1069,6 +1069,10 @@ def mutate(cls, root, info, ids):
class ReadSecretMutation(graphene.Mutation):
+ # SSO middleware reads this to resolve org from the bare `ids`
+ # kwarg (output is `ok`, no model implicit in return type).
+ org_resource_model = "Secret"
+
class Arguments:
ids = graphene.List(graphene.ID)
diff --git a/backend/backend/graphene/mutations/organisation.py b/backend/backend/graphene/mutations/organisation.py
index 422621ee0..f62957854 100644
--- a/backend/backend/graphene/mutations/organisation.py
+++ b/backend/backend/graphene/mutations/organisation.py
@@ -25,6 +25,7 @@
OrganisationType,
)
from datetime import timedelta
+from django.contrib.auth import login
from django.utils import timezone
from django.conf import settings
@@ -91,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
@@ -111,6 +136,178 @@ def mutate(cls, root, info, org_id, wrapped_keyring, wrapped_recovery):
return UpdateUserWrappedSecretsMutation(org_member=org_member)
+class RecoverAccountKeyringMutation(graphene.Mutation):
+ """Rewrap THIS org's keyring with a deviceKey derived from the user's
+ account password. Used by the recovery flow when the local keyring
+ has been lost (cleared cache, new device) but the user still
+ remembers their password.
+
+ 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. auth_hash matches user.password — proves the password the user
+ is wrapping the keyring with is also their account login auth.
+
+ The mutation does NOT change user.password. The auth_hash check is a
+ guardrail to keep auth and wrap passwords unified; if it fails, the
+ user is trying to wrap the keyring with a password that doesn't
+ authenticate them, which we never persist.
+ """
+
+ class Arguments:
+ org_id = graphene.ID(required=True)
+ auth_hash = graphene.String(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,
+ auth_hash,
+ identity_key,
+ wrapped_keyring,
+ wrapped_recovery,
+ ):
+ request = info.context
+ user = request.user
+
+ if not user.has_usable_password():
+ raise GraphQLError("No account password set for SSO users.")
+
+ 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(
+ user=user, organisation=org, deleted_at=None
+ )
+ except OrganisationMember.DoesNotExist:
+ raise GraphQLError("Not a member of this organisation.")
+
+ if not user.check_password(auth_hash):
+ raise GraphQLError(
+ "Password does not match your account. Use your "
+ "current login password to recover this organisation."
+ )
+
+ with transaction.atomic():
+ org_member.wrapped_keyring = wrapped_keyring
+ org_member.wrapped_recovery = wrapped_recovery or ""
+ org_member.save()
+
+ 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 RecoverAccountKeyringMutation(org_member=org_member)
+
+
+class ChangeAccountPasswordMutation(graphene.Mutation):
+ """Rotate the user's account password and rewrap the active org's
+ keyring with the new deviceKey. Used by the in-session change-password
+ dialog where the user supplies their current password, a new password,
+ and the org's recovery mnemonic.
+
+ Three server-side proofs are required:
+ 1. current_auth_hash matches user.password — proves the caller
+ knows the current login password.
+ 2. identity_key matches the org's stored identity_key — proves the
+ caller derived the keyring from the right mnemonic.
+ 3. user is a member of the org.
+
+ On success: user.password is set to new_auth_hash, the org's
+ wrapped_keyring + wrapped_recovery are replaced, and the session is
+ refreshed so the post-rotation HASH_SESSION_KEY stays valid.
+
+ Only the active org's keyring is rewrapped. Other orgs the user
+ belongs to remain encrypted with the old deviceKey; they'll fall
+ through to per-org recovery on next access.
+ """
+
+ class Arguments:
+ org_id = graphene.ID(required=True)
+ current_auth_hash = graphene.String(required=True)
+ new_auth_hash = graphene.String(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,
+ current_auth_hash,
+ new_auth_hash,
+ identity_key,
+ wrapped_keyring,
+ wrapped_recovery,
+ ):
+ request = info.context
+ user = request.user
+
+ if not user.has_usable_password():
+ raise GraphQLError("SSO users cannot change their password here.")
+
+ if not user.check_password(current_auth_hash):
+ raise GraphQLError("Current password is incorrect.")
+
+ 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(
+ user=user, organisation=org, deleted_at=None
+ )
+ except OrganisationMember.DoesNotExist:
+ raise GraphQLError("Not a member of this organisation.")
+
+ with transaction.atomic():
+ user.set_password(new_auth_hash)
+ user.save()
+
+ org_member.wrapped_keyring = wrapped_keyring
+ org_member.wrapped_recovery = wrapped_recovery or ""
+ org_member.save()
+
+ 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 ChangeAccountPasswordMutation(org_member=org_member)
+
+
class InviteInput(graphene.InputObjectType):
email = graphene.String(required=True)
apps = graphene.List(graphene.String)
@@ -258,6 +455,25 @@ def mutate(
id=invite_id, valid=True, expires_at__gte=timezone.now()
)
+ # The invite is bound to a specific email. A valid session alone
+ # is not enough — the authenticated caller's email MUST match
+ # the invitee. Otherwise a leaked invite_id could be claimed by
+ # any account holder. resolve_validate_invite already enforces
+ # this for the read path, but this mutation is the canonical
+ # write gate and must enforce independently.
+ if invite.invitee_email.lower() != info.context.user.email.lower():
+ raise GraphQLError("This invite is for another user")
+
+ # An invite is bound to a specific organisation. The client-
+ # supplied org_id must match — otherwise a legitimate invite
+ # to org A can be redeemed to join org B (seat bumps, role
+ # assignment, membership creation) by anyone who knows B's
+ # UUID. UUIDs leak via URLs, logs, screenshots.
+ if str(invite.organisation_id) != str(org_id):
+ raise GraphQLError(
+ "Invite does not match the specified organisation"
+ )
+
org = Organisation.objects.get(id=org_id)
role = (
diff --git a/backend/backend/graphene/mutations/sso.py b/backend/backend/graphene/mutations/sso.py
new file mode 100644
index 000000000..b912ef6f9
--- /dev/null
+++ b/backend/backend/graphene/mutations/sso.py
@@ -0,0 +1,342 @@
+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):
+ # SSO config mutations bypass OrgSSOEnforcementMiddleware so a
+ # locked-out admin can recover from require_sso=True with no
+ # working provider. Owner/Admin role gating in mutate() is the
+ # only safety net here — keep it.
+ bypass_sso_enforcement = True
+
+ 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):
+ # See CreateOrganisationSSOProviderMutation — admins must be able
+ # to deactivate the only enabled provider to recover from a
+ # require_sso=True lockout. The auto-flip at lines ~150-170 below
+ # depends on this mutation actually reaching its mutate().
+ bypass_sso_enforcement = True
+
+ 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()
+ from backend.graphene.middleware import (
+ OrgSSOEnforcementMiddleware,
+ )
+ OrgSSOEnforcementMiddleware.invalidate_decision(
+ provider.organisation_id
+ )
+ provider.enabled = enabled
+
+ provider.updated_by = member
+ provider.save()
+
+ return UpdateOrganisationSSOProviderMutation(ok=True)
+
+
+class DeleteOrganisationSSOProviderMutation(graphene.Mutation):
+ # See CreateOrganisationSSOProviderMutation.
+ bypass_sso_enforcement = True
+
+ 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()
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+ OrgSSOEnforcementMiddleware.invalidate_decision(
+ provider.organisation_id
+ )
+
+ provider.delete()
+
+ return DeleteOrganisationSSOProviderMutation(ok=True)
+
+
+class TestOrganisationSSOProviderMutation(graphene.Mutation):
+ # See CreateOrganisationSSOProviderMutation. Admins need to be
+ # able to test a replacement provider before re-enabling
+ # require_sso, even from a password session.
+ bypass_sso_enforcement = True
+
+ class Arguments:
+ provider_id = graphene.ID(required=True)
+
+ success = graphene.Boolean()
+ error = graphene.String()
+
+ @classmethod
+ def mutate(cls, root, info, provider_id):
+ from api.views.sso import _safe_oidc_request
+
+ 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"
+ # 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 = _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:
+ 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):
+ # The toggle that flips require_sso. Must be reachable from a
+ # password session so an admin who's locked themselves out can
+ # disable enforcement.
+ bypass_sso_enforcement = True
+
+ 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()
+
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+ OrgSSOEnforcementMiddleware.invalidate_decision(org.id)
+
+ # 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/queries/auth.py b/backend/backend/graphene/queries/auth.py
new file mode 100644
index 000000000..2a1264b3f
--- /dev/null
+++ b/backend/backend/graphene/queries/auth.py
@@ -0,0 +1,18 @@
+from graphql import GraphQLError
+
+
+def resolve_verify_password(root, info, auth_hash):
+ """Verify that the supplied authHash matches the session user's stored password.
+
+ Returns True on match, False on mismatch. Used by onboarding/invite flows
+ to confirm the user still knows their account password before deriving
+ the deviceKey from it (so the deviceKey can't drift from the auth password).
+ """
+ user = info.context.user
+ if not user or not getattr(user, "is_authenticated", False):
+ raise GraphQLError("Authentication required")
+
+ if not auth_hash:
+ return False
+
+ return user.check_password(auth_hash)
diff --git a/backend/backend/graphene/types.py b/backend/backend/graphene/types.py
index 286bf5567..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"):
@@ -195,7 +238,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):
diff --git a/backend/backend/schema.py b/backend/backend/schema.py
index 280476c4b..3ce37a4f4 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,
@@ -116,6 +123,7 @@
)
from .graphene.queries.quotas import resolve_organisation_plan
from .graphene.queries.license import resolve_license, resolve_organisation_license
+from .graphene.queries.auth import resolve_verify_password
from .graphene.mutations.environment import (
BulkCreateSecretMutation,
BulkDeleteSecretMutation,
@@ -178,10 +186,12 @@
)
from .graphene.mutations.organisation import (
BulkInviteOrganisationMembersMutation,
+ ChangeAccountPasswordMutation,
CreateOrganisationMemberMutation,
CreateOrganisationMutation,
DeleteInviteMutation,
DeleteOrganisationMemberMutation,
+ RecoverAccountKeyringMutation,
TransferOrganisationOwnershipMutation,
UpdateOrganisationMemberRole,
UpdateUserWrappedSecretsMutation,
@@ -199,6 +209,7 @@
OrganisationMemberInviteType,
OrganisationMemberType,
OrganisationPlanType,
+ OrganisationSSOProviderType,
OrganisationType,
PhaseLicenseType,
ProviderCredentialsType,
@@ -267,6 +278,8 @@ class Query(graphene.ObjectType):
organisation_name_available = graphene.Boolean(name=graphene.String())
+ verify_password = graphene.Boolean(auth_hash=graphene.String(required=True))
+
license = graphene.Field(PhaseLicenseType)
organisation_license = graphene.Field(
@@ -553,6 +566,8 @@ def resolve_organisation_name_available(root, info, name):
resolve_license = resolve_license
resolve_organisation_license = resolve_organisation_license
+ resolve_verify_password = resolve_verify_password
+
def resolve_organisation_members(
root, info, organisation_id, role=None, member_id=None
):
@@ -1055,6 +1070,8 @@ class Mutation(graphene.ObjectType):
update_organisation_member_role = UpdateOrganisationMemberRole.Field()
transfer_organisation_ownership = TransferOrganisationOwnershipMutation.Field()
update_member_wrapped_secrets = UpdateUserWrappedSecretsMutation.Field()
+ recover_account_keyring = RecoverAccountKeyringMutation.Field()
+ change_account_password = ChangeAccountPasswordMutation.Field()
delete_invitation = DeleteInviteMutation.Field()
@@ -1090,6 +1107,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 = (
diff --git a/backend/backend/settings.py b/backend/backend/settings.py
index 604fe90bd..712cd2128 100644
--- a/backend/backend/settings.py
+++ b/backend/backend/settings.py
@@ -238,6 +238,10 @@ def get_version():
AUTH_USER_MODEL = "api.CustomUser"
+PASSWORD_HASHERS = [
+ "django.contrib.auth.hashers.Argon2PasswordHasher",
+]
+
REST_AUTH_SERIALIZERS = {
"USER_DETAILS_SERIALIZER": "api.serializers.CustomUserSerializer"
}
@@ -274,6 +278,7 @@ def get_version():
GRAPHENE = {
"SCHEMA": "backend.schema.schema",
"MIDDLEWARE": [
+ "backend.graphene.middleware.OrgSSOEnforcementMiddleware",
"backend.graphene.middleware.IPWhitelistMiddleware",
],
}
diff --git a/backend/backend/urls.py b/backend/backend/urls.py
index d95337bda..87506c106 100644
--- a/backend/backend/urls.py
+++ b/backend/backend/urls.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from django.urls import path, include, re_path
+from django.urls import path, include
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from api.views.lockbox import LockboxView
@@ -12,6 +12,20 @@
secrets_tokens,
root_endpoint,
)
+from api.views.sso import (
+ auth_me,
+ OrgSSOAuthorizeView,
+ SSOAuthorizeView,
+ SSOCallbackView,
+)
+from api.views.auth_password import (
+ password_register,
+ password_login,
+ 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
from api.views.kms import kms
@@ -26,10 +40,19 @@
"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("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
+ path("auth/password/register/", password_register),
+ path("auth/password/login/", password_login),
+ 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/backend/conftest.py b/backend/conftest.py
index a169614a2..a8cba84c0 100644
--- a/backend/conftest.py
+++ b/backend/conftest.py
@@ -5,6 +5,10 @@
os.environ.setdefault("ALLOWED_HOSTS", "localhost")
os.environ.setdefault("ALLOWED_ORIGINS", "http://localhost")
+# Set dummy secrets so settings.py can import without errors
+os.environ.setdefault("SECRET_KEY", "test-secret-key-not-for-production")
+os.environ.setdefault("SERVER_SECRET", "test-server-secret-not-for-production")
+
# Set dummy Redis values so settings.py generates a valid URL (e.g. redis://localhost:6379/1)
os.environ.setdefault("REDIS_HOST", "localhost")
os.environ.setdefault("REDIS_PORT", "6379")
@@ -21,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",
+ }
+ }
diff --git a/backend/ee/authentication/sso/oauth/github_enterprise/views.py b/backend/ee/authentication/sso/oauth/github_enterprise/views.py
index bdf114cf6..5fec6466e 100644
--- a/backend/ee/authentication/sso/oauth/github_enterprise/views.py
+++ b/backend/ee/authentication/sso/oauth/github_enterprise/views.py
@@ -20,7 +20,7 @@ class GitHubEnterpriseOAuth2Adapter(GitHubOAuth2Adapter):
settings = app_settings.PROVIDERS.get(provider_id, {})
web_url = os.getenv("GITHUB_ENTERPRISE_BASE_URL", "").rstrip("/")
- api_url = f"{os.getenv("GITHUB_ENTERPRISE_API_URL", "").rstrip("/")}/v3"
+ api_url = "{}/v3".format(os.getenv("GITHUB_ENTERPRISE_API_URL", "").rstrip("/"))
access_token_url = f"{web_url}/login/oauth/access_token"
authorize_url = f"{web_url}/login/oauth/authorize"
diff --git a/backend/ee/authentication/sso/oidc/entraid/views.py b/backend/ee/authentication/sso/oidc/entraid/views.py
index 1a8f948b1..9433d489c 100644
--- a/backend/ee/authentication/sso/oidc/entraid/views.py
+++ b/backend/ee/authentication/sso/oidc/entraid/views.py
@@ -1,5 +1,6 @@
import jwt
import json
+import os
from api.models import ActivatedPhaseLicense
from django.conf import settings
from django.utils import timezone
@@ -11,6 +12,89 @@
logger = logging.getLogger(__name__)
+# Multi-tenant JWKS — PyJWKClient picks the right key by `kid`.
+_MS_JWKS_URL = "https://login.microsoftonline.com/common/discovery/v2.0/keys"
+
+# Issuer is built from the configured tenant_id; both `iss` and `tid`
+# get pinned to it during validation.
+_MS_ISSUER_PREFIX = "https://login.microsoftonline.com/"
+_MS_ISSUER_SUFFIX = "/v2.0"
+
+
+def _validate_ms_id_token(
+ id_token,
+ audience,
+ expected_tenant_id,
+ expected_nonce=None,
+):
+ """Verify signature (via /common JWKS), audience, expiry, issuer,
+ tenant, and nonce. `expected_tenant_id` is required — without it,
+ any Microsoft tenant's token would validate."""
+ if not expected_tenant_id:
+ logger.error("Entra ID validation: no expected tenant_id configured")
+ raise OAuth2Error("Tenant configuration missing — cannot verify token.")
+
+ try:
+ jwk_client = jwt.PyJWKClient(_MS_JWKS_URL)
+ signing_key = jwk_client.get_signing_key_from_jwt(id_token)
+ except Exception as e:
+ logger.error(f"Microsoft JWKS fetch failed: {e}")
+ raise OAuth2Error("Unable to verify ID token (JWKS fetch failed).")
+
+ expected_issuer = (
+ f"{_MS_ISSUER_PREFIX}{expected_tenant_id}{_MS_ISSUER_SUFFIX}"
+ )
+ try:
+ claims = jwt.decode(
+ id_token,
+ key=signing_key.key,
+ algorithms=["RS256"],
+ audience=audience,
+ issuer=expected_issuer,
+ )
+ except jwt.InvalidTokenError as e:
+ logger.error(f"Microsoft ID token validation failed: {e}")
+ raise OAuth2Error(f"Invalid ID token: {e}")
+
+ # Defense-in-depth: assert `tid` matches the configured tenant even
+ # after PyJWT's issuer check.
+ tid = claims.get("tid", "")
+ if tid.lower() != expected_tenant_id.lower():
+ logger.error(
+ f"Microsoft ID token tenant mismatch: tid={tid!r} != "
+ f"expected={expected_tenant_id!r}"
+ )
+ raise OAuth2Error("Invalid ID token: tenant mismatch")
+
+ if expected_nonce is not None:
+ if claims.get("nonce") != expected_nonce:
+ logger.error("Microsoft ID token nonce mismatch — possible replay")
+ raise OAuth2Error("Invalid ID token: nonce mismatch")
+
+ return claims
+
+
+def _resolve_expected_tenant_id(request):
+ """Org-level: tenant from `OrganisationSSOProvider.config`.
+ Instance-level: tenant from `ENTRA_ID_OIDC_TENANT_ID` env."""
+ session = getattr(request, "session", None)
+ org_config_id = session.get("sso_org_config_id") if session else None
+ if org_config_id:
+ try:
+ from api.utils.sso import get_org_sso_config
+
+ _provider, org_config = get_org_sso_config(org_config_id)
+ tenant = org_config.get("tenant_id") if org_config else None
+ if tenant:
+ return tenant
+ except Exception as e:
+ logger.error(
+ f"Failed to load org SSO config {org_config_id}: {e}"
+ )
+ return None
+
+ return os.getenv("ENTRA_ID_OIDC_TENANT_ID")
+
class CustomMicrosoftGraphOAuth2Adapter(MicrosoftGraphOAuth2Adapter):
def _check_microsoft_errors(self, response):
@@ -30,25 +114,47 @@ def _check_microsoft_errors(self, response):
data["name"] = data.get("displayName")
if data["name"] is None:
- data["name"] = f"{data.get("givenName")} {data.get("surName")}"
+ data["name"] = "{} {}".format(data.get("givenName"), data.get("surName"))
return data
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 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))
+
+ # Microsoft returns the ID token in the token exchange response
+ # alongside the access token. Validate it properly — signature,
+ # audience, issuer, expiry, and nonce — BEFORE trusting any of
+ # its claims.
+ response_payload = kwargs.get("response") or {}
+ id_token = getattr(token, "id_token", None) or response_payload.get(
+ "id_token"
+ )
+ if not id_token:
+ raise OAuth2Error(
+ "Microsoft Entra ID did not return an id_token; refusing to "
+ "accept access-token claims unverified."
+ )
- 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))
+ expected_nonce = (
+ request.session.get("sso_nonce")
+ if hasattr(request, "session")
+ else None
+ )
+ expected_tenant_id = _resolve_expected_tenant_id(request)
+ validated_claims = _validate_ms_id_token(
+ id_token,
+ audience=app.client_id,
+ expected_tenant_id=expected_tenant_id,
+ expected_nonce=expected_nonce,
+ )
headers = {"Authorization": "Bearer {0}".format(token.token)}
response = (
@@ -65,23 +171,22 @@ def complete_login(self, request, app, token, **kwargs):
login = self.get_provider().sociallogin_from_response(request, extra_data)
- try:
- claims = jwt.decode(token.token, options={"verify_signature": False})
-
- email = claims.get("email") or claims.get(
- "preferred_username"
- ) # Microsoft may use "preferred_username"
- if email:
- login.user.email = email
- except jwt.DecodeError as ex:
- print(ex)
- pass # Handle decoding errors if necessary
+ # Email now comes from the validated ID token (or the Graph
+ # response as a fallback). Either way the IdP has been
+ # cryptographically verified.
+ email = (
+ validated_claims.get("email")
+ or validated_claims.get("preferred_username")
+ or extra_data.get("mail")
+ or extra_data.get("userPrincipalName")
+ )
+ if email:
+ login.user.email = email
try:
- email = login.user.email
full_name = extra_data.get("name", "")
- send_login_email(request, email, full_name, "Microsoft Entra ID")
+ send_login_email(request, login.user.email, full_name, "Microsoft Entra ID")
except Exception as e:
- print(f"Error sending email: {e}")
+ logger.error(f"Error sending login email: {e}")
return login
diff --git a/backend/ee/authentication/sso/oidc/okta/views.py b/backend/ee/authentication/sso/oidc/okta/views.py
index c4aead7f9..371d42906 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,27 +47,32 @@ 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)
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')}"
)
@@ -74,6 +80,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..ef97ac132 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,27 +32,31 @@ 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)
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')}"
)
@@ -59,6 +64,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..5103afdad 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,27 +34,31 @@ 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)
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')}"
)
@@ -61,6 +66,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:
diff --git a/backend/requirements.txt b/backend/requirements.txt
index b8c12dd4a..dd162586d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,4 +1,5 @@
aniso8601==9.0.1
+argon2-cffi==23.1.0
asgiref==3.8.1
async-timeout==4.0.3
attrs==22.2.0
diff --git a/backend/tests/test_auth_password.py b/backend/tests/test_auth_password.py
new file mode 100644
index 000000000..71cde8367
--- /dev/null
+++ b/backend/tests/test_auth_password.py
@@ -0,0 +1,1251 @@
+"""Tests for password auth endpoints (api.views.auth_password).
+
+Uses unittest.TestCase with mocked ORM — no database required.
+Throttling is disabled for all tests via setUp() cache clearing.
+"""
+
+import json
+import unittest
+from datetime import timedelta
+from unittest.mock import patch, MagicMock
+
+from django.core.cache import cache
+from django.test import RequestFactory
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.utils import timezone
+from rest_framework.test import APIRequestFactory, force_authenticate
+
+from api.views.auth_password import (
+ password_register,
+ password_login,
+ verify_email,
+ resend_verification,
+ email_check,
+)
+
+
+class _ThrottleClearMixin:
+ """Clear DRF throttle cache before each test."""
+
+ def setUp(self):
+ super().setUp()
+ cache.clear()
+
+
+def _add_session_to_request(request):
+ """Attach session support to a bare RequestFactory request."""
+ middleware = SessionMiddleware(lambda req: None)
+ middleware.process_request(request)
+ request.session.save()
+
+
+def _make_post(path, data, user=None):
+ """Create a POST request with JSON body and session."""
+ 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):
+ """Create a GET request with session."""
+ factory = RequestFactory()
+ request = factory.get(path)
+ _add_session_to_request(request)
+ return request
+
+
+# ---------------------------------------------------------------------------
+# password_register
+# ---------------------------------------------------------------------------
+
+class PasswordRegisterTest(_ThrottleClearMixin, unittest.TestCase):
+ """Tests for POST /auth/password/register/."""
+
+ VALID_PAYLOAD = {
+ "email": "alice@example.com",
+ "authHash": "a" * 64,
+ "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, mock_smtp
+ ):
+ """Successful registration creates user + verification token."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ mock_get_user.return_value = User
+
+ new_user = MagicMock()
+ new_user.active = True
+ User.objects.create_user.return_value = new_user
+
+ request = _make_post("/auth/password/register/", self.VALID_PAYLOAD)
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 201)
+ data = json.loads(response.content)
+ self.assertIn("Verification", data["message"])
+
+ User.objects.create_user.assert_called_once()
+ 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."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = True
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", self.VALID_PAYLOAD)
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 409)
+
+ def test_register_rejects_missing_fields(self):
+ """Registration fails with missing required fields."""
+ request = _make_post("/auth/password/register/", {"email": "a@b.com"})
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 400)
+
+ def test_register_rejects_invalid_email(self):
+ """Registration fails with badly formatted email."""
+ payload = dict(self.VALID_PAYLOAD, email="not-an-email")
+ request = _make_post("/auth/password/register/", payload)
+ response = password_register(request)
+
+ 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
+# ---------------------------------------------------------------------------
+
+class VerifyEmailTest(_ThrottleClearMixin, unittest.TestCase):
+ """Tests for GET /auth/verify-email//."""
+
+ @patch("api.views.auth_password.EmailVerification")
+ def test_valid_token_activates_user(self, mock_ev_cls):
+ """Valid non-expired token activates user and sets verified."""
+ ev = MagicMock()
+ ev.verified = False
+ ev.expires_at = timezone.now() + timedelta(hours=1)
+ ev.user = MagicMock()
+ mock_ev_cls.objects.select_related.return_value.get.return_value = ev
+
+ request = _make_get("/auth/verify-email/test-token/")
+ response = verify_email(request, "test-token")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("verified=true", response.url)
+ self.assertTrue(ev.verified)
+ ev.save.assert_called()
+ ev.user.save.assert_called()
+
+ @patch("api.views.auth_password.EmailVerification")
+ def test_expired_token_rejected(self, mock_ev_cls):
+ """Expired token redirects with error."""
+ ev = MagicMock()
+ ev.verified = False
+ ev.expires_at = timezone.now() - timedelta(hours=1)
+ mock_ev_cls.objects.select_related.return_value.get.return_value = ev
+
+ request = _make_get("/auth/verify-email/expired-token/")
+ response = verify_email(request, "expired-token")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("verification_expired", response.url)
+
+ @patch("api.views.auth_password.EmailVerification")
+ def test_invalid_token_rejected(self, mock_ev_cls):
+ """Non-existent token redirects with error."""
+ from api.models import EmailVerification as EV
+
+ mock_ev_cls.DoesNotExist = EV.DoesNotExist
+ mock_ev_cls.objects.select_related.return_value.get.side_effect = EV.DoesNotExist
+
+ request = _make_get("/auth/verify-email/bad-token/")
+ response = verify_email(request, "bad-token")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("invalid_verification_token", response.url)
+
+ @patch("api.views.auth_password.EmailVerification")
+ def test_already_verified_redirects_success(self, mock_ev_cls):
+ """Already-verified token is idempotent."""
+ ev = MagicMock()
+ ev.verified = True
+ mock_ev_cls.objects.select_related.return_value.get.return_value = ev
+
+ request = _make_get("/auth/verify-email/already-done/")
+ response = verify_email(request, "already-done")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("verified=true", response.url)
+
+
+# ---------------------------------------------------------------------------
+# password_login
+# ---------------------------------------------------------------------------
+
+class PasswordLoginTest(_ThrottleClearMixin, unittest.TestCase):
+ """Tests for POST /auth/password/login/."""
+
+ @patch("api.views.auth_password.login")
+ @patch("api.views.auth_password.get_user_model")
+ def test_login_succeeds_with_correct_hash(self, mock_get_user, mock_login):
+ """Correct authHash logs user in and returns user info."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = True
+ user.userId = "uuid-123"
+ user.email = "alice@example.com"
+ user.full_name = ""
+ 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["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."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = True
+ user.check_password.return_value = False
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ request = _make_post(
+ "/auth/password/login/",
+ {"email": "alice@example.com", "authHash": "wrong"},
+ )
+ response = password_login(request)
+
+ self.assertEqual(response.status_code, 401)
+
+ @patch("api.views.auth_password.get_user_model")
+ def test_login_fails_if_not_verified(self, mock_get_user):
+ """Unverified user (active=False) returns 403."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = False
+ 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, 403)
+
+ @patch("api.views.auth_password.get_user_model")
+ def test_login_fails_for_nonexistent_user(self, mock_get_user):
+ """Non-existent email returns 401 (same as wrong password)."""
+ User = MagicMock()
+ from api.models import CustomUser
+
+ User.DoesNotExist = CustomUser.DoesNotExist
+ User.objects.get.side_effect = CustomUser.DoesNotExist
+ mock_get_user.return_value = User
+
+ request = _make_post(
+ "/auth/password/login/",
+ {"email": "nobody@example.com", "authHash": "a" * 64},
+ )
+ response = password_login(request)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_login_rejects_missing_fields(self):
+ """Missing email or authHash returns 400."""
+ request = _make_post("/auth/password/login/", {"email": "a@b.com"})
+ response = password_login(request)
+
+ self.assertEqual(response.status_code, 400)
+
+
+# ---------------------------------------------------------------------------
+# Verification email logging
+# ---------------------------------------------------------------------------
+
+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, mock_smtp
+ ):
+ """Registration always calls _send_verification_email which logs."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ new_user = MagicMock()
+ User.objects.create_user.return_value = new_user
+ mock_get_user.return_value = User
+
+ payload = {
+ "email": "bob@example.com",
+ "authHash": "h" * 64,
+ }
+ request = _make_post("/auth/password/register/", payload)
+ password_register(request)
+
+ mock_send_email.assert_called_once()
+ call_args = mock_send_email.call_args
+ self.assertEqual(call_args[0][0], "bob@example.com")
+ self.assertIn("/auth/verify-email/", call_args[0][1])
+
+
+# ---------------------------------------------------------------------------
+# email_check
+# ---------------------------------------------------------------------------
+
+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, 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.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_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
+ 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.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, mock_om, mock_sso):
+ """Unknown email returns password=True, sso=[] (no enumeration leak)."""
+ User = MagicMock()
+ from api.models import CustomUser
+ User.DoesNotExist = CustomUser.DoesNotExist
+ User.objects.get.side_effect = CustomUser.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"], [])
+
+ def test_rejects_missing_email(self):
+ """Missing email returns 400."""
+ request = _make_post("/auth/email/check/", {})
+ response = email_check(request)
+
+ self.assertEqual(response.status_code, 400)
+
+
+# ---------------------------------------------------------------------------
+# resend_verification
+# ---------------------------------------------------------------------------
+
+class ResendVerificationTest(_ThrottleClearMixin, unittest.TestCase):
+ """Tests for POST /auth/verify-email/resend/."""
+
+ @patch("api.views.auth_password._send_verification_email")
+ @patch("api.views.auth_password.EmailVerification")
+ @patch("api.views.auth_password.get_user_model")
+ def test_resend_creates_new_token(self, mock_get_user, mock_ev, mock_send_email):
+ """Resend deletes old token and creates new one."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = False
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/verify-email/resend/", {"email": "alice@example.com"})
+ response = resend_verification(request)
+
+ self.assertEqual(response.status_code, 200)
+ mock_ev.objects.filter.return_value.delete.assert_called_once()
+ mock_ev.objects.create.assert_called_once()
+ mock_send_email.assert_called_once()
+
+ @patch("api.views.auth_password.get_user_model")
+ def test_resend_does_not_leak_for_unknown_email(self, mock_get_user):
+ """Unknown email gets same success message (no enumeration)."""
+ User = MagicMock()
+ from api.models import CustomUser
+ User.DoesNotExist = CustomUser.DoesNotExist
+ User.objects.get.side_effect = CustomUser.DoesNotExist
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/verify-email/resend/", {"email": "unknown@example.com"})
+ response = resend_verification(request)
+
+ self.assertEqual(response.status_code, 200)
+
+ @patch("api.views.auth_password.get_user_model")
+ def test_resend_noop_for_already_active_user(self, mock_get_user):
+ """Already-active user gets same success message, no new token."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = True
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/verify-email/resend/", {"email": "active@example.com"})
+ response = resend_verification(request)
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_resend_rejects_missing_email(self):
+ """Missing email returns 400."""
+ request = _make_post("/auth/verify-email/resend/", {})
+ response = resend_verification(request)
+
+ self.assertEqual(response.status_code, 400)
+
+
+# ---------------------------------------------------------------------------
+# skip email verification flag
+# ---------------------------------------------------------------------------
+
+class SkipEmailVerificationTest(_ThrottleClearMixin, unittest.TestCase):
+ """Tests for SKIP_EMAIL_VERIFICATION env var."""
+
+ @patch("api.views.auth_password._skip_email_verification", 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_register_skips_verification(self, mock_get_user, mock_ev, mock_tx, mock_skip):
+ """With SKIP_EMAIL_VERIFICATION, user is active immediately."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ new_user = MagicMock()
+ User.objects.create_user.return_value = new_user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", {
+ "email": "quick@example.com",
+ "authHash": "a" * 64,
+ })
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 201)
+ data = json.loads(response.content)
+ self.assertTrue(data["verificationSkipped"])
+ # User should be set to active=True (skip_verification)
+ self.assertTrue(new_user.active)
+ # 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, mock_smtp
+ ):
+ """Without flag, user is inactive and verification token is created."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ new_user = MagicMock()
+ User.objects.create_user.return_value = new_user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", {
+ "email": "normal@example.com",
+ "authHash": "a" * 64,
+ })
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 201)
+ data = json.loads(response.content)
+ self.assertNotIn("verificationSkipped", data)
+ mock_ev.objects.create.assert_called_once()
+ mock_send_email.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# ALLOW_SIGNUPS gate
+# ---------------------------------------------------------------------------
+
+class AllowSignupsGateTest(_ThrottleClearMixin, unittest.TestCase):
+ """When the operator sets ALLOW_SIGNUPS=false, password_register
+ refuses brand-new strangers but still admits invited emails.
+ Existing-user sign-in is unaffected and tested elsewhere."""
+
+ VALID_PAYLOAD = {
+ "email": "stranger@example.com",
+ "authHash": "a" * 64,
+ "fullName": "Stranger",
+ }
+
+ @patch("api.views.auth_password._has_pending_invite", return_value=False)
+ @patch("api.views.auth_password._signups_allowed", return_value=False)
+ @patch("api.views.auth_password.get_user_model")
+ def test_register_refused_for_stranger_when_signups_disabled(
+ self, mock_get_user, _flag, _invite
+ ):
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", self.VALID_PAYLOAD)
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 403)
+ body = json.loads(response.content)["error"].lower()
+ self.assertIn("disabled", body)
+ User.objects.create_user.assert_not_called()
+
+ @patch("api.views.auth_password._smtp_configured", return_value=False)
+ @patch("api.views.auth_password.transaction")
+ @patch("api.views.auth_password._has_pending_invite", return_value=True)
+ @patch("api.views.auth_password._signups_allowed", return_value=False)
+ @patch("api.views.auth_password.get_user_model")
+ def test_register_admits_invitee_when_signups_disabled(
+ self, mock_get_user, _flag, _invite, _tx, _smtp
+ ):
+ """Invited emails bypass the gate — invites are how operators
+ onboard people on closed instances."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ new_user = MagicMock()
+ new_user.active = True
+ User.objects.create_user.return_value = new_user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", self.VALID_PAYLOAD)
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 201)
+ User.objects.create_user.assert_called_once()
+
+ @patch("api.views.auth_password._smtp_configured", return_value=False)
+ @patch("api.views.auth_password.transaction")
+ @patch("api.views.auth_password._has_pending_invite", return_value=False)
+ @patch("api.views.auth_password._signups_allowed", return_value=True)
+ @patch("api.views.auth_password.get_user_model")
+ def test_register_baseline_when_signups_enabled(
+ self, mock_get_user, _flag, _invite, _tx, _smtp
+ ):
+ """Default behaviour: signups open, no invite needed."""
+ User = MagicMock()
+ User.objects.filter.return_value.exists.return_value = False
+ new_user = MagicMock()
+ new_user.active = True
+ User.objects.create_user.return_value = new_user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/register/", self.VALID_PAYLOAD)
+ response = password_register(request)
+
+ self.assertEqual(response.status_code, 201)
+ User.objects.create_user.assert_called_once()
+
+
+class SignupsAllowedHelperTest(unittest.TestCase):
+ """`_signups_allowed` reads ALLOW_SIGNUPS env var. Default is open;
+ only an explicit falsy value disables. Typos fail open."""
+
+ def _check(self, env_value, expected):
+ env = {} if env_value is None else {"ALLOW_SIGNUPS": env_value}
+ with patch.dict("os.environ", env, clear=False):
+ if env_value is None:
+ # Force-unset so getenv falls through to default
+ import os as _os
+ _os.environ.pop("ALLOW_SIGNUPS", None)
+ from api.views.auth_password import _signups_allowed
+ self.assertEqual(_signups_allowed(), expected, env_value)
+
+ def test_default_is_open(self):
+ self._check(None, True)
+
+ def test_explicit_false_disables(self):
+ for val in ("false", "FALSE", "False", "0", "no", "NO"):
+ self._check(val, False)
+
+ def test_truthy_keeps_open(self):
+ for val in ("true", "1", "yes", "TRUE"):
+ self._check(val, True)
+
+ def test_typo_fails_open(self):
+ """An operator who writes ALLOW_SIGNUPS=disabled doesn't get
+ what they expect — but they get the safer of the two interpretations
+ (signups still open). Better than silently locking everyone out."""
+ self._check("disabled", True)
+
+
+# ===========================================================================
+# End-to-end flow tests (multi-step scenarios)
+# ===========================================================================
+
+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, mock_smtp
+ ):
+ """Register → verify email → login succeeds."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = False
+ user.userId = "uuid-pw-1"
+ user.email = "newuser@example.com"
+ user.full_name = ""
+ user.auth_method = "password"
+ user.has_usable_password.return_value = True
+ user.socialaccount_set.first.return_value = None
+
+ User.objects.filter.return_value.exists.return_value = False
+ User.objects.create_user.return_value = user
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ # Step 1: Register
+ reg_request = _make_post("/auth/password/register/", {
+ "email": "newuser@example.com",
+ "authHash": "h" * 64,
+ })
+ reg_response = password_register(reg_request)
+ self.assertEqual(reg_response.status_code, 201)
+ mock_send_email.assert_called_once()
+
+ # Step 2: Verify email
+ ev = MagicMock()
+ ev.verified = False
+ ev.expires_at = timezone.now() + timedelta(hours=1)
+ ev.user = user
+ mock_ev.objects.select_related.return_value.get.return_value = ev
+
+ verify_request = _make_get("/auth/verify-email/token123/")
+ verify_response = verify_email(verify_request, "token123")
+ self.assertEqual(verify_response.status_code, 302)
+ self.assertIn("verified=true", verify_response.url)
+
+ # Step 3: Login (user now active)
+ user.active = True
+ user.check_password.return_value = True
+
+ login_request = _make_post("/auth/password/login/", {
+ "email": "newuser@example.com",
+ "authHash": "h" * 64,
+ })
+ login_response = password_login(login_request)
+ self.assertEqual(login_response.status_code, 200)
+ data = json.loads(login_response.content)
+ 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, mock_smtp
+ ):
+ """User cannot login before verifying email."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = False
+ User.objects.filter.return_value.exists.return_value = False
+ User.objects.create_user.return_value = user
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ # Register
+ reg_request = _make_post("/auth/password/register/", {
+ "email": "unverified@example.com",
+ "authHash": "h" * 64,
+ })
+ password_register(reg_request)
+
+ # Try to login without verifying — should fail
+ login_request = _make_post("/auth/password/login/", {
+ "email": "unverified@example.com",
+ "authHash": "h" * 64,
+ })
+ login_response = password_login(login_request)
+ self.assertEqual(login_response.status_code, 403)
+
+
+class SSOSignupFlowTest(_ThrottleClearMixin, unittest.TestCase):
+ """SSO login creates user with unusable password."""
+
+ def test_sso_user_has_unusable_password(self):
+ """SSO-created user has auth_method=sso and can't password-login."""
+ user = MagicMock()
+ user.has_usable_password.return_value = False
+ user.auth_method = "sso"
+
+ # Verify auth_method is sso
+ self.assertEqual(user.auth_method, "sso")
+
+ @patch("api.views.auth_password.get_user_model")
+ def test_sso_user_cannot_password_login(self, mock_get_user):
+ """SSO user with unusable password fails password login."""
+ User = MagicMock()
+ user = MagicMock()
+ user.active = True
+ user.check_password.return_value = False # unusable password always fails
+ User.objects.get.return_value = user
+ mock_get_user.return_value = User
+
+ request = _make_post("/auth/password/login/", {
+ "email": "sso-user@example.com",
+ "authHash": "anything",
+ })
+ 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_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
+ 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.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, mock_om, mock_sso):
+ """Unknown email and password user both return password=True, sso=[]."""
+ User = MagicMock()
+ from api.models import CustomUser
+
+ # Unknown email
+ User.DoesNotExist = CustomUser.DoesNotExist
+ User.objects.get.side_effect = CustomUser.DoesNotExist
+ mock_get_user.return_value = User
+
+ req1 = _make_post("/auth/email/check/", {"email": "unknown@example.com"})
+ resp1 = email_check(req1)
+ data1 = json.loads(resp1.content)
+
+ # Password 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 authMethods
+ self.assertEqual(data1["authMethods"], data2["authMethods"])
+ self.assertTrue(data1["authMethods"]["password"])
+ self.assertEqual(data1["authMethods"]["sso"], [])
+
+
+class PasswordChangeFlowTest(_ThrottleClearMixin, unittest.TestCase):
+ """ChangeAccountPasswordMutation rotates the auth hash AND re-wraps
+ THIS org's keyring atomically. Other orgs are left encrypted with the
+ old key and surface a recovery prompt on next access."""
+
+ def _info(self, user):
+ from django.contrib.sessions.middleware import SessionMiddleware
+ info = MagicMock()
+ request = APIRequestFactory().post("/graphql/")
+ SessionMiddleware(lambda r: None).process_request(request)
+ request.session.save()
+ request.user = user
+ info.context = request
+ return info
+
+ @patch("backend.graphene.mutations.organisation.login")
+ @patch("backend.graphene.mutations.organisation.transaction")
+ @patch("backend.graphene.mutations.organisation.OrganisationMember")
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_password_change_rewraps_current_org_keyring(
+ self, mock_org, mock_om, mock_tx, mock_login
+ ):
+ from backend.graphene.mutations.organisation import (
+ ChangeAccountPasswordMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+ user.check_password.return_value = True
+
+ 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 = ChangeAccountPasswordMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-a",
+ current_auth_hash="old_hash",
+ new_auth_hash="new_hash",
+ identity_key="matching_key",
+ wrapped_keyring="wk_a",
+ wrapped_recovery="wr_a",
+ )
+
+ user.set_password.assert_called_once_with("new_hash")
+ self.assertEqual(org_member.wrapped_keyring, "wk_a")
+ self.assertEqual(org_member.wrapped_recovery, "wr_a")
+ org_member.save.assert_called_once()
+ mock_login.assert_called_once()
+ self.assertIs(result.org_member, org_member)
+
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_password_change_rejects_wrong_current_password(self, mock_org):
+ """Wrong current password is rejected before any org lookup."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ ChangeAccountPasswordMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+ user.check_password.return_value = False
+
+ with self.assertRaises(GraphQLError):
+ ChangeAccountPasswordMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-a",
+ current_auth_hash="wrong",
+ new_auth_hash="new_hash",
+ identity_key="k",
+ wrapped_keyring="wk",
+ wrapped_recovery="wr",
+ )
+ user.set_password.assert_not_called()
+ mock_org.objects.get.assert_not_called()
+
+ @patch("backend.graphene.mutations.organisation.OrganisationMember")
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_password_change_rejects_wrong_identity_key(self, mock_org, mock_om):
+ """Mnemonic must match the org's stored identity_key — proves the
+ user has the keyring material and isn't just guessing UUIDs."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ ChangeAccountPasswordMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+ user.check_password.return_value = True
+
+ org = MagicMock()
+ org.identity_key = "real_key"
+ mock_org.objects.get.return_value = org
+
+ with self.assertRaises(GraphQLError):
+ ChangeAccountPasswordMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-a",
+ current_auth_hash="old_hash",
+ new_auth_hash="new_hash",
+ identity_key="wrong_key",
+ wrapped_keyring="wk_a",
+ wrapped_recovery="wr_a",
+ )
+ user.set_password.assert_not_called()
+ mock_om.objects.get.assert_not_called()
+
+ def test_sso_user_cannot_change_password(self):
+ """SSO users (unusable password) are blocked."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ ChangeAccountPasswordMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = False
+
+ with self.assertRaises(GraphQLError):
+ ChangeAccountPasswordMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-a",
+ current_auth_hash="x",
+ new_auth_hash="y",
+ identity_key="k",
+ wrapped_keyring="wk",
+ wrapped_recovery="wr",
+ )
+ user.set_password.assert_not_called()
+
+
+class RecoveryFlowTest(_ThrottleClearMixin, unittest.TestCase):
+ """Recovery via mnemonic, exposed as the GraphQL mutation
+ RecoverAccountKeyringMutation. Password must match user's
+ current login auth (auth and sudo stay unified). Only the org's
+ keyring is rewrapped; user.password is never reset because if the
+ hashes match, it's already correct."""
+
+ def _info(self, user):
+ from django.contrib.sessions.middleware import SessionMiddleware
+ info = MagicMock()
+ request = APIRequestFactory().post("/graphql/")
+ SessionMiddleware(lambda r: None).process_request(request)
+ request.session.save()
+ request.user = user
+ info.context = request
+ return info
+
+ @patch("backend.graphene.mutations.organisation.login")
+ @patch("backend.graphene.mutations.organisation.transaction")
+ @patch("backend.graphene.mutations.organisation.OrganisationMember")
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_password_user_recovery_rewraps_keyring_when_auth_matches(
+ self, mock_org, mock_om, mock_tx, mock_login
+ ):
+ from backend.graphene.mutations.organisation import (
+ RecoverAccountKeyringMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+ user.check_password.return_value = True # supplied authHash matches
+
+ 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 = RecoverAccountKeyringMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-1",
+ auth_hash="current_hash",
+ identity_key="matching_key",
+ wrapped_keyring="new_wk",
+ wrapped_recovery="new_wr",
+ )
+
+ # Auth password is already correct — set_password should NOT be called.
+ user.set_password.assert_not_called()
+ self.assertEqual(org_member.wrapped_keyring, "new_wk")
+ self.assertEqual(org_member.wrapped_recovery, "new_wr")
+ org_member.save.assert_called_once()
+ mock_login.assert_called_once()
+ self.assertIs(result.org_member, org_member)
+
+ @patch("backend.graphene.mutations.organisation.OrganisationMember")
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_recovery_rejects_password_mismatch(self, mock_org, mock_om):
+ """Regression: a user who recovers an org's keyring with a
+ password DIFFERENT from their current login auth would otherwise
+ end up with split auth/sudo passwords. The mutation must refuse."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ RecoverAccountKeyringMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+ user.check_password.return_value = False # supplied hash != stored
+
+ org = MagicMock()
+ org.identity_key = "matching_key"
+ mock_org.objects.get.return_value = org
+ mock_om.objects.get.return_value = MagicMock()
+
+ with self.assertRaises(GraphQLError):
+ RecoverAccountKeyringMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-1",
+ auth_hash="wrong_pw_hash",
+ identity_key="matching_key",
+ wrapped_keyring="new_wk",
+ wrapped_recovery="new_wr",
+ )
+ user.set_password.assert_not_called()
+
+ @patch("backend.graphene.mutations.organisation.Organisation")
+ def test_recovery_rejects_wrong_identity_key(self, mock_org):
+ """Wrong identity key is rejected before any keyring write."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ RecoverAccountKeyringMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = True
+
+ org = MagicMock()
+ org.identity_key = "real_key"
+ mock_org.objects.get.return_value = org
+
+ with self.assertRaises(GraphQLError):
+ RecoverAccountKeyringMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-1",
+ auth_hash="hash",
+ identity_key="wrong_key",
+ wrapped_keyring="x",
+ wrapped_recovery="y",
+ )
+ user.set_password.assert_not_called()
+ user.check_password.assert_not_called()
+
+ def test_sso_user_recovery_rejected(self):
+ """SSO users have no password to reset — mutation refuses."""
+ from graphql import GraphQLError
+ from backend.graphene.mutations.organisation import (
+ RecoverAccountKeyringMutation,
+ )
+ user = MagicMock()
+ user.has_usable_password.return_value = False
+
+ with self.assertRaises(GraphQLError):
+ RecoverAccountKeyringMutation.mutate(
+ None,
+ self._info(user),
+ org_id="org-1",
+ auth_hash="anything",
+ identity_key="k",
+ wrapped_keyring="x",
+ wrapped_recovery="y",
+ )
+ 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."""
+
+ @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, 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.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_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
+ 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.assertFalse(data["authMethods"]["password"])
+ self.assertEqual(data["authMethods"]["sso"], [])
diff --git a/backend/tests/test_authelia_provider.py b/backend/tests/test_authelia_provider.py
index 407f61dcc..c0c886cb1 100644
--- a/backend/tests/test_authelia_provider.py
+++ b/backend/tests/test_authelia_provider.py
@@ -43,8 +43,21 @@
}
-def _make_mock_request():
- return MagicMock()
+def _make_mock_request(sso_nonce=None):
+ """Build a minimal request mock with a realistic session.get.
+
+ The generic adapter reads `request.session.get("sso_nonce")` to
+ enforce OIDC nonce validation; without a real dict behind it,
+ MagicMock's auto-spec returns a MagicMock (truthy) and causes the
+ adapter to reject the login. Back it with a dict so callers can
+ explicitly set a nonce when they want the check to run.
+ """
+ req = MagicMock()
+ session = {}
+ if sso_nonce is not None:
+ session["sso_nonce"] = sso_nonce
+ req.session.get.side_effect = session.get
+ return req
def _make_mock_app(client_id="phase-console"):
@@ -153,7 +166,7 @@ def test_successful_oidc_discovery(self, mock_get):
adapter._fetch_oidc_config()
# Assert: endpoints come from the discovery response, not defaults
- mock_get.assert_called_with(adapter.oidc_config_url)
+ mock_get.assert_called_with(adapter.oidc_config_url, allow_redirects=False)
self.assertEqual(adapter.access_token_url, OIDC_DISCOVERY_RESPONSE["token_endpoint"])
self.assertEqual(adapter.authorize_url, OIDC_DISCOVERY_RESPONSE["authorization_endpoint"])
self.assertEqual(adapter.profile_url, OIDC_DISCOVERY_RESPONSE["userinfo_endpoint"])
@@ -288,9 +301,9 @@ def test_complete_login_userinfo_fallback_when_id_token_is_none(self, mock_get):
"api.authentication.adapters.generic.views.jwt.decode",
)
@patch(
- "api.authentication.adapters.generic.views.requests.get",
+ "api.authentication.adapters.generic.views.jwt.PyJWKClient",
)
- def test_complete_login_with_id_token_from_kwargs_response(self, mock_get, mock_jwt_decode):
+ def test_complete_login_with_id_token_from_kwargs_response(self, mock_jwk_client_cls, mock_jwt_decode):
"""
When id_token is available in kwargs["response"], it should be
decoded via JWKS instead of hitting the userinfo endpoint.
@@ -303,10 +316,11 @@ def test_complete_login_with_id_token_from_kwargs_response(self, mock_get, mock_
mock_provider.sociallogin_from_response.return_value = mock_login
adapter.get_provider = MagicMock(return_value=mock_provider)
- mock_jwks = {"keys": [{"kty": "RSA", "kid": "test-key"}]}
- mock_jwks_resp = MagicMock()
- mock_jwks_resp.json.return_value = mock_jwks
- mock_get.return_value = mock_jwks_resp
+ mock_signing_key = MagicMock()
+ mock_signing_key.key = "mock-key"
+ mock_jwk_client = MagicMock()
+ mock_jwk_client.get_signing_key_from_jwt.return_value = mock_signing_key
+ mock_jwk_client_cls.return_value = mock_jwk_client
mock_jwt_decode.return_value = ID_TOKEN_CLAIMS
@@ -320,11 +334,11 @@ def test_complete_login_with_id_token_from_kwargs_response(self, mock_get, mock_
request, app, token, response={"id_token": fake_id_token_jwt}
)
- # Assert: JWKS endpoint was fetched and JWT was decoded
- mock_get.assert_called_once_with(f"{AUTHELIA_BASE_URL}/jwks.json")
+ # Assert: PyJWKClient was used and JWT was decoded
+ mock_jwk_client_cls.assert_called_once_with(f"{AUTHELIA_BASE_URL}/jwks.json")
mock_jwt_decode.assert_called_once_with(
fake_id_token_jwt,
- key=mock_jwks,
+ key="mock-key",
algorithms=["RS256"],
audience="phase-console",
issuer=AUTHELIA_BASE_URL,
@@ -338,9 +352,9 @@ def test_complete_login_with_id_token_from_kwargs_response(self, mock_get, mock_
"api.authentication.adapters.generic.views.jwt.decode",
)
@patch(
- "api.authentication.adapters.generic.views.requests.get",
+ "api.authentication.adapters.generic.views.jwt.PyJWKClient",
)
- def test_complete_login_with_id_token_on_token_object(self, mock_get, mock_jwt_decode):
+ def test_complete_login_with_id_token_on_token_object(self, mock_jwk_client_cls, mock_jwt_decode):
"""
When token.id_token is set directly (not the typical Authelia path,
but supported by the generic adapter).
@@ -353,10 +367,11 @@ def test_complete_login_with_id_token_on_token_object(self, mock_get, mock_jwt_d
mock_provider.sociallogin_from_response.return_value = mock_login
adapter.get_provider = MagicMock(return_value=mock_provider)
- mock_jwks = {"keys": [{"kty": "RSA", "kid": "test-key"}]}
- mock_jwks_resp = MagicMock()
- mock_jwks_resp.json.return_value = mock_jwks
- mock_get.return_value = mock_jwks_resp
+ mock_signing_key = MagicMock()
+ mock_signing_key.key = "mock-key"
+ mock_jwk_client = MagicMock()
+ mock_jwk_client.get_signing_key_from_jwt.return_value = mock_signing_key
+ mock_jwk_client_cls.return_value = mock_jwk_client
mock_jwt_decode.return_value = ID_TOKEN_CLAIMS
@@ -371,7 +386,7 @@ def test_complete_login_with_id_token_on_token_object(self, mock_get, mock_jwt_d
# Assert: id_token from token object was used
mock_jwt_decode.assert_called_once_with(
fake_id_token_jwt,
- key=mock_jwks,
+ key="mock-key",
algorithms=["RS256"],
audience="phase-console",
issuer=AUTHELIA_BASE_URL,
@@ -463,6 +478,40 @@ def test_fetch_user_info_401_raises(self, mock_get):
with self.assertRaises(HTTPError):
adapter._fetch_user_info(token)
+ def test_get_user_data_refuses_userinfo_when_nonce_expected(self):
+ """Userinfo fallback can't bind to the auth request — refuse
+ if a nonce was issued (would silently skip replay check)."""
+ from allauth.socialaccount.providers.oauth2.views import OAuth2Error
+
+ adapter = self._make_adapter()
+ token = _make_mock_token(access_token="t")
+ app = _make_mock_app(client_id="phase-console")
+
+ with self.assertRaises(OAuth2Error) as ctx:
+ adapter._get_user_data(
+ token, id_token=None, app=app, expected_nonce="n"
+ )
+ self.assertIn("nonce verification", str(ctx.exception).lower())
+
+ @patch("api.authentication.adapters.generic.views.requests.get")
+ def test_get_user_data_falls_through_to_userinfo_when_no_nonce(self, mock_get):
+ """No-nonce flows still use the userinfo fallback."""
+ adapter = self._make_adapter()
+ adapter.profile_url = f"{AUTHELIA_BASE_URL}/api/oidc/userinfo"
+
+ mock_resp = MagicMock()
+ mock_resp.json.return_value = USERINFO_RESPONSE
+ mock_resp.raise_for_status = MagicMock()
+ mock_get.return_value = mock_resp
+
+ token = _make_mock_token(access_token="t")
+ app = _make_mock_app(client_id="phase-console")
+
+ result = adapter._get_user_data(
+ token, id_token=None, app=app, expected_nonce=None
+ )
+ self.assertEqual(result, USERINFO_RESPONSE)
+
# ---------------------------------------------------------------------------
# Tests for _process_id_token
@@ -486,16 +535,17 @@ def _make_adapter(self):
"api.authentication.adapters.generic.views.jwt.decode",
)
@patch(
- "api.authentication.adapters.generic.views.requests.get",
+ "api.authentication.adapters.generic.views.jwt.PyJWKClient",
)
- def test_process_id_token_success(self, mock_get, mock_jwt_decode):
+ def test_process_id_token_success(self, mock_jwk_client_cls, mock_jwt_decode):
# Arrange
adapter = self._make_adapter()
- mock_jwks = {"keys": [{"kty": "RSA", "kid": "authelia-key-1"}]}
- mock_jwks_resp = MagicMock()
- mock_jwks_resp.json.return_value = mock_jwks
- mock_get.return_value = mock_jwks_resp
+ mock_signing_key = MagicMock()
+ mock_signing_key.key = "mock-key"
+ mock_jwk_client = MagicMock()
+ mock_jwk_client.get_signing_key_from_jwt.return_value = mock_signing_key
+ mock_jwk_client_cls.return_value = mock_jwk_client
mock_jwt_decode.return_value = ID_TOKEN_CLAIMS
@@ -506,10 +556,11 @@ def test_process_id_token_success(self, mock_get, mock_jwt_decode):
result = adapter._process_id_token(fake_jwt, app)
# Assert
- mock_get.assert_called_once_with(f"{AUTHELIA_BASE_URL}/jwks.json")
+ mock_jwk_client_cls.assert_called_once_with(f"{AUTHELIA_BASE_URL}/jwks.json")
+ mock_jwk_client.get_signing_key_from_jwt.assert_called_once_with(fake_jwt)
mock_jwt_decode.assert_called_once_with(
fake_jwt,
- key=mock_jwks,
+ key="mock-key",
algorithms=["RS256"],
audience="phase-console",
issuer=AUTHELIA_BASE_URL,
@@ -522,15 +573,17 @@ def test_process_id_token_success(self, mock_get, mock_jwt_decode):
"api.authentication.adapters.generic.views.jwt.decode",
)
@patch(
- "api.authentication.adapters.generic.views.requests.get",
+ "api.authentication.adapters.generic.views.jwt.PyJWKClient",
)
- def test_process_id_token_invalid_raises_oauth2error(self, mock_get, mock_jwt_decode):
+ def test_process_id_token_invalid_raises_oauth2error(self, mock_jwk_client_cls, mock_jwt_decode):
# Arrange
adapter = self._make_adapter()
- mock_jwks_resp = MagicMock()
- mock_jwks_resp.json.return_value = {"keys": []}
- mock_get.return_value = mock_jwks_resp
+ mock_signing_key = MagicMock()
+ mock_signing_key.key = "mock-key"
+ mock_jwk_client = MagicMock()
+ mock_jwk_client.get_signing_key_from_jwt.return_value = mock_signing_key
+ mock_jwk_client_cls.return_value = mock_jwk_client
import jwt as pyjwt
@@ -758,15 +811,17 @@ def test_routes_to_userinfo_when_no_id_token(self, mock_get):
"api.authentication.adapters.generic.views.jwt.decode",
)
@patch(
- "api.authentication.adapters.generic.views.requests.get",
+ "api.authentication.adapters.generic.views.jwt.PyJWKClient",
)
- def test_routes_to_id_token_processing_when_id_token_present(self, mock_get, mock_jwt_decode):
+ def test_routes_to_id_token_processing_when_id_token_present(self, mock_jwk_client_cls, mock_jwt_decode):
# Arrange
adapter = self._make_adapter()
- mock_jwks_resp = MagicMock()
- mock_jwks_resp.json.return_value = {"keys": []}
- mock_get.return_value = mock_jwks_resp
+ mock_signing_key = MagicMock()
+ mock_signing_key.key = "mock-key"
+ mock_jwk_client = MagicMock()
+ mock_jwk_client.get_signing_key_from_jwt.return_value = mock_signing_key
+ mock_jwk_client_cls.return_value = mock_jwk_client
mock_jwt_decode.return_value = ID_TOKEN_CLAIMS
diff --git a/backend/tests/test_org_resolution.py b/backend/tests/test_org_resolution.py
new file mode 100644
index 000000000..5917f22eb
--- /dev/null
+++ b/backend/tests/test_org_resolution.py
@@ -0,0 +1,289 @@
+"""Tests for api.utils.access.org_resolution.
+
+Covers the three cache layers (per-request dict, Django cache / Redis,
+DB fallback), auto-discovery of the FK path to Organisation, signal-
+driven invalidation, and graceful degradation when the cache backend
+is unavailable.
+"""
+
+import unittest
+from unittest.mock import patch, MagicMock
+
+from django.core.cache import cache
+
+
+class PathDiscoveryTest(unittest.TestCase):
+ """BFS through Django model FKs to find the path to Organisation."""
+
+ def setUp(self):
+ from api.utils.access.org_resolution import _path_to_organisation
+ # Each test starts with a fresh lru_cache so path discovery
+ # re-runs against the current app state.
+ _path_to_organisation.cache_clear()
+
+ def test_organisation_itself_resolves_to_id(self):
+ from api.utils.access.org_resolution import _path_to_organisation
+ self.assertEqual(_path_to_organisation("Organisation"), "id")
+
+ def test_direct_fk_to_organisation(self):
+ """App.organisation is a direct FK — path is one hop."""
+ from api.utils.access.org_resolution import _path_to_organisation
+ path = _path_to_organisation("App")
+ self.assertEqual(path, "organisation__id")
+
+ def test_transitive_fk_chain(self):
+ """Secret → Environment → App → Organisation — three hops."""
+ from api.utils.access.org_resolution import _path_to_organisation
+ path = _path_to_organisation("Secret")
+ # Exact path depends on FK layout; just assert it ends at
+ # organisation__id and goes through environment + app.
+ self.assertTrue(path.endswith("organisation__id"))
+ self.assertIn("environment", path)
+
+ def test_unknown_model_returns_none(self):
+ from api.utils.access.org_resolution import _path_to_organisation
+ self.assertIsNone(_path_to_organisation("Nonexistent"))
+
+
+class ResolveOrgIdTest(unittest.TestCase):
+ """End-to-end org-id resolution with all three cache layers."""
+
+ def setUp(self):
+ cache.clear()
+ # Clear lru_cache so each test sees a fresh path lookup.
+ from api.utils.access.org_resolution import _path_to_organisation
+ _path_to_organisation.cache_clear()
+
+ def test_direct_org_id_kwargs_bypass_lookups(self):
+ """organisation_id and org_id are passed through verbatim — no
+ DB query, no cache hit."""
+ from api.utils.access.org_resolution import resolve_org_id
+ request_cache = {}
+ with patch("api.utils.access.org_resolution.apps.get_model") as mock_get:
+ self.assertEqual(
+ resolve_org_id("organisation_id", "abc", request_cache), "abc"
+ )
+ self.assertEqual(
+ resolve_org_id("org_id", "xyz", request_cache), "xyz"
+ )
+ mock_get.assert_not_called()
+
+ def test_non_id_kwarg_returns_none(self):
+ """A kwarg that isn't a *_id shape should be ignored."""
+ from api.utils.access.org_resolution import resolve_org_id
+ self.assertIsNone(resolve_org_id("name", "something", {}))
+ self.assertIsNone(resolve_org_id("email", "x@y.com", {}))
+
+ def test_empty_value_returns_none(self):
+ from api.utils.access.org_resolution import resolve_org_id
+ self.assertIsNone(resolve_org_id("app_id", None, {}))
+ self.assertIsNone(resolve_org_id("app_id", "", {}))
+
+ def test_ambiguous_kwarg_skipped(self):
+ """token_id deliberately bypasses the generic resolver — the
+ middleware has a dedicated probe for it."""
+ from api.utils.access.org_resolution import resolve_org_id
+ self.assertIsNone(resolve_org_id("token_id", "tkn-1", {}))
+
+ def test_l1_cache_hit_skips_redis_and_db(self):
+ from api.utils.access.org_resolution import resolve_org_id
+ request_cache = {("app_id", "app-1"): "org-pre-cached"}
+ with patch("api.utils.access.org_resolution.apps.get_model") as mock_get:
+ with patch("api.utils.access.org_resolution.cache") as mock_cache:
+ result = resolve_org_id("app_id", "app-1", request_cache)
+ self.assertEqual(result, "org-pre-cached")
+ mock_get.assert_not_called()
+ mock_cache.get.assert_not_called()
+
+ def test_l2_cache_hit_populates_l1(self):
+ from api.utils.access.org_resolution import resolve_org_id
+ request_cache = {}
+ # Pre-seed L2 (cache) with a known value
+ cache.set("org_for:app:app-2", "org-from-redis", timeout=60)
+ with patch("api.utils.access.org_resolution.apps.get_model") as mock_get:
+ result = resolve_org_id("app_id", "app-2", request_cache)
+ self.assertEqual(result, "org-from-redis")
+ mock_get.assert_not_called()
+ # L1 should now contain the result too
+ self.assertEqual(request_cache.get(("app_id", "app-2")), "org-from-redis")
+
+ def test_l2_negative_sentinel_treated_as_not_found(self):
+ """An empty-string cache entry means 'we looked, it wasn't
+ there'. Return None without hitting the DB."""
+ from api.utils.access.org_resolution import resolve_org_id
+ cache.set("org_for:app:ghost-app", "", timeout=60)
+ with patch("api.utils.access.org_resolution.apps.get_model") as mock_get:
+ result = resolve_org_id("app_id", "ghost-app", {})
+ self.assertIsNone(result)
+ mock_get.assert_not_called()
+
+ def test_cache_miss_falls_through_to_db_and_populates_both_layers(self):
+ from api.utils.access.org_resolution import resolve_org_id
+
+ mock_model = MagicMock()
+ mock_model.objects.filter.return_value.values_list.return_value.first.return_value = (
+ "org-from-db"
+ )
+
+ request_cache = {}
+ with patch(
+ "api.utils.access.org_resolution.apps.get_model",
+ return_value=mock_model,
+ ), patch(
+ "api.utils.access.org_resolution._path_to_organisation",
+ return_value="organisation__id",
+ ):
+ result = resolve_org_id("app_id", "app-3", request_cache)
+
+ self.assertEqual(result, "org-from-db")
+ # L1 populated
+ self.assertEqual(request_cache.get(("app_id", "app-3")), "org-from-db")
+ # L2 populated
+ self.assertEqual(cache.get("org_for:app:app-3"), "org-from-db")
+
+ def test_redis_failure_falls_through_to_db(self):
+ """If Redis is down, cache ops raise — we must not break the
+ request path. Resolution must complete via the DB."""
+ from api.utils.access.org_resolution import resolve_org_id
+
+ mock_model = MagicMock()
+ mock_model.objects.filter.return_value.values_list.return_value.first.return_value = (
+ "org-from-db"
+ )
+
+ class _BrokenCache:
+ def get(self, *args, **kwargs):
+ raise ConnectionError("redis down")
+
+ def set(self, *args, **kwargs):
+ raise ConnectionError("redis down")
+
+ with patch(
+ "api.utils.access.org_resolution.apps.get_model",
+ return_value=mock_model,
+ ), patch(
+ "api.utils.access.org_resolution._path_to_organisation",
+ return_value="organisation__id",
+ ), patch(
+ "api.utils.access.org_resolution.cache", new=_BrokenCache()
+ ):
+ result = resolve_org_id("app_id", "app-4", {})
+
+ self.assertEqual(result, "org-from-db")
+
+ def test_provider_id_alias_resolves(self):
+ """Regression: provider_id was the middleware bypass that motivated
+ the auto-discovery refactor. It must resolve via the
+ OrganisationSSOProvider alias."""
+ from api.utils.access.org_resolution import resolve_org_id
+
+ mock_model = MagicMock()
+ mock_model.objects.filter.return_value.values_list.return_value.first.return_value = (
+ "org-for-provider"
+ )
+
+ with patch(
+ "api.utils.access.org_resolution.apps.get_model",
+ return_value=mock_model,
+ ) as mock_get, patch(
+ "api.utils.access.org_resolution._path_to_organisation",
+ return_value="organisation__id",
+ ):
+ result = resolve_org_id("provider_id", "p-1", {})
+
+ self.assertEqual(result, "org-for-provider")
+ # Must have looked up the right Django model.
+ mock_get.assert_called_once_with("api", "OrganisationSSOProvider")
+
+
+class InvalidationTest(unittest.TestCase):
+ """post_delete signal drops the cache entry so hard-deleted resources
+ don't leave stale mappings in Redis."""
+
+ def setUp(self):
+ cache.clear()
+
+ def test_invalidate_drops_cache_entry(self):
+ from api.utils.access.org_resolution import invalidate_org_for
+ from api.models import App
+
+ cache.set("org_for:app:some-id", "org-1", timeout=60)
+ self.assertEqual(cache.get("org_for:app:some-id"), "org-1")
+
+ instance = MagicMock()
+ instance.pk = "some-id"
+ invalidate_org_for(App, instance.pk)
+ self.assertIsNone(cache.get("org_for:app:some-id"))
+
+ def test_invalidate_covers_aliased_kind(self):
+ """OrganisationSSOProvider is cached under the alias kwarg prefix
+ ('provider') because that's the only kwarg shape resolvers use.
+ Invalidation must clear that key."""
+ from api.utils.access.org_resolution import invalidate_org_for
+ from api.models import OrganisationSSOProvider
+
+ cache.set("org_for:provider:p-1", "org-a", timeout=60)
+ invalidate_org_for(OrganisationSSOProvider, "p-1")
+ self.assertIsNone(cache.get("org_for:provider:p-1"))
+
+
+class MiddlewareFastPathTest(unittest.TestCase):
+ """The session fast-path skips the decision cache and DB lookup
+ entirely when the session is already SSO-bound to the target org."""
+
+ class _StubRequest:
+ def __init__(self, session):
+ self.user = type("U", (), {"is_authenticated": True})()
+ self.session = session
+
+ def _info(self, session):
+ info = MagicMock()
+ info.context = self._StubRequest(session)
+ return info
+
+ def _next(self, root, info, **kwargs):
+ return "ran"
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_sso_session_bound_to_org_skips_db(self, mock_org_cls):
+ """Session has auth_method=sso + matching auth_sso_org_id → no
+ DB query, no cache lookup, just pass through."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ mw = OrgSSOEnforcementMiddleware()
+ result = mw.resolve(
+ self._next,
+ None,
+ self._info({"auth_method": "sso", "auth_sso_org_id": "org-1"}),
+ organisation_id="org-1",
+ )
+
+ self.assertEqual(result, "ran")
+ mock_org_cls.objects.only.return_value.get.assert_not_called()
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_sso_session_bound_to_different_org_does_not_skip(
+ self, mock_org_cls
+ ):
+ """Session bound to org A must NOT short-circuit for requests
+ targeting org B — the DB check must run to enforce B's require_sso."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ cache.clear()
+ org = MagicMock(require_sso=False)
+ org.name = "acme"
+ mock_org_cls.objects.only.return_value.get.return_value = org
+
+ mw = OrgSSOEnforcementMiddleware()
+ mw.resolve(
+ self._next,
+ None,
+ self._info({"auth_method": "sso", "auth_sso_org_id": "org-A"}),
+ organisation_id="org-B",
+ )
+
+ mock_org_cls.objects.only.return_value.get.assert_called_once()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_org_sso.py b/backend/tests/test_org_sso.py
new file mode 100644
index 000000000..76b8edddc
--- /dev/null
+++ b/backend/tests/test_org_sso.py
@@ -0,0 +1,2195 @@
+"""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
+ org.name = "Acme Corp"
+
+ 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
+ org.name = "Acme Corp"
+
+ 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))
+
+
+# ---------------------------------------------------------------------------
+# Org-SSO callback hand-off to invite-acceptance wizard
+# ---------------------------------------------------------------------------
+
+class OrgSSOInviteRedirectTest(unittest.TestCase):
+ """Regression: completing an org-level SSO login with a pending
+ invite logged the user in but didn't consume the invite (acceptance
+ must run client-side for keyring derivation). Without the redirect,
+ the user landed on /onboard with no org membership. The callback
+ now hands off to /invite/ when an invite is open and the
+ user isn't already a member."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def _build_callback_request(self, state="state-xyz"):
+ request = self.factory.get(
+ f"/auth/sso/entra-id-oidc/callback/?code=auth-code&state={state}"
+ )
+ _add_session_to_request(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = "entra-id-oidc"
+ request.session["sso_callback_url"] = (
+ "https://console.phase.dev/api/auth/callback/entra-id-oidc"
+ )
+ request.session["sso_token_url"] = "https://idp/token"
+ request.session["sso_org_config_id"] = "cfg-1"
+ request.session.save()
+ return request
+
+ @patch("api.models.OrganisationMemberInvite")
+ @patch("api.models.OrganisationMember")
+ @patch("api.views.sso.OrganisationSSOProvider")
+ @patch("api.views.sso._complete_login_bypassing_allauth")
+ @patch("api.views.sso._get_or_create_social_app")
+ @patch("api.views.sso._get_adapter_instance")
+ @patch("api.views.sso._exchange_code_for_token")
+ @patch("api.utils.sso.get_org_sso_config")
+ @patch("api.utils.sso.get_org_provider_meta")
+ def test_redirects_to_invite_wizard_when_pending_invite_exists(
+ self,
+ mock_get_meta,
+ mock_get_org_cfg,
+ mock_exchange,
+ mock_get_adapter,
+ mock_get_app,
+ mock_complete_login,
+ mock_org_provider_cls,
+ mock_member_cls,
+ mock_invite_cls,
+ ):
+ from api.views.sso import SSOCallbackView
+
+ # Org provider config returns a viable shape.
+ provider = MagicMock()
+ provider.organisation_id = "org-1"
+ provider.id = "cfg-1"
+ provider.provider_type = "entra_id"
+ mock_get_org_cfg.return_value = (
+ provider,
+ {"client_id": "x", "client_secret": "y", "issuer": "https://idp"},
+ )
+ mock_get_meta.return_value = {
+ "callback_slug": "entra-id-oidc",
+ "adapter_module": "ee.authentication.sso.oidc.entraid.views",
+ "adapter_class": "CustomMicrosoftGraphOAuth2Adapter",
+ "provider_id": "entra-id-oidc",
+ }
+ mock_org_provider_cls.objects.get.return_value = provider
+
+ # Token exchange + adapter both succeed.
+ mock_exchange.return_value = {
+ "access_token": "at",
+ "id_token": "idt",
+ }
+ adapter = MagicMock()
+ social_login = MagicMock()
+ social_login.user.email = "newcomer@example.com"
+ social_login.account.extra_data = {"email": "newcomer@example.com"}
+ adapter.complete_login.return_value = social_login
+ mock_get_adapter.return_value = adapter
+ mock_get_app.return_value = MagicMock()
+
+ # Login completes; user is authenticated post-callback.
+ user = MagicMock()
+ user.is_authenticated = True
+ user.email = "newcomer@example.com"
+ mock_complete_login.return_value = user
+
+ # No prior membership, but a valid invite exists for this email.
+ mock_member_cls.objects.filter.return_value.exists.return_value = False
+ invite = MagicMock()
+ invite.id = "inv-uuid-12345"
+ mock_invite_cls.objects.filter.return_value.first.return_value = invite
+
+ request = self._build_callback_request()
+ request.user = user
+
+ with patch("api.views.sso.SocialToken"), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+ response = SSOCallbackView().get(request, "entra-id-oidc")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("/invite/", response.url)
+ # Invite ID is base64-encoded in the path.
+ from base64 import b64decode
+ path_segment = response.url.rsplit("/", 1)[-1]
+ self.assertEqual(b64decode(path_segment).decode(), "inv-uuid-12345")
+
+ @patch("api.models.OrganisationMemberInvite")
+ @patch("api.models.OrganisationMember")
+ @patch("api.views.sso.OrganisationSSOProvider")
+ @patch("api.views.sso._complete_login_bypassing_allauth")
+ @patch("api.views.sso._get_or_create_social_app")
+ @patch("api.views.sso._get_adapter_instance")
+ @patch("api.views.sso._exchange_code_for_token")
+ @patch("api.utils.sso.get_org_sso_config")
+ @patch("api.utils.sso.get_org_provider_meta")
+ def test_returning_member_skips_invite_redirect(
+ self,
+ mock_get_meta,
+ mock_get_org_cfg,
+ mock_exchange,
+ mock_get_adapter,
+ mock_get_app,
+ mock_complete_login,
+ mock_org_provider_cls,
+ mock_member_cls,
+ mock_invite_cls,
+ ):
+ """A user already in the org goes to `/`, not `/invite/`."""
+ from api.views.sso import SSOCallbackView
+
+ provider = MagicMock()
+ provider.organisation_id = "org-1"
+ provider.id = "cfg-1"
+ mock_get_org_cfg.return_value = (
+ provider,
+ {"client_id": "x", "client_secret": "y", "issuer": "https://idp"},
+ )
+ mock_get_meta.return_value = {
+ "callback_slug": "entra-id-oidc",
+ "adapter_module": "ee.authentication.sso.oidc.entraid.views",
+ "adapter_class": "CustomMicrosoftGraphOAuth2Adapter",
+ "provider_id": "entra-id-oidc",
+ }
+ mock_org_provider_cls.objects.get.return_value = provider
+
+ mock_exchange.return_value = {"access_token": "at", "id_token": "idt"}
+ adapter = MagicMock()
+ social_login = MagicMock()
+ social_login.user.email = "alice@example.com"
+ social_login.account.extra_data = {"email": "alice@example.com"}
+ adapter.complete_login.return_value = social_login
+ mock_get_adapter.return_value = adapter
+ mock_get_app.return_value = MagicMock()
+
+ user = MagicMock()
+ user.is_authenticated = True
+ user.email = "alice@example.com"
+ mock_complete_login.return_value = user
+
+ # Existing membership → skip the invite branch entirely.
+ mock_member_cls.objects.filter.return_value.exists.return_value = True
+
+ request = self._build_callback_request()
+ request.user = user
+
+ with patch("api.views.sso.SocialToken"), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+ response = SSOCallbackView().get(request, "entra-id-oidc")
+
+ self.assertEqual(response.status_code, 302)
+ self.assertNotIn("/invite/", response.url)
+ self.assertEqual(response.url.rstrip("/"), "https://console.phase.dev")
+
+
+# ---------------------------------------------------------------------------
+# Cloud-mode guard removal
+# ---------------------------------------------------------------------------
+
+class EntraIdTenantPinningTest(unittest.TestCase):
+ """`_validate_ms_id_token` pins `tid` to the configured tenant.
+ Without it, any Microsoft tenant could authenticate via /common."""
+
+ @patch("ee.authentication.sso.oidc.entraid.views.jwt")
+ def test_rejects_token_from_wrong_tenant(self, mock_jwt):
+ from ee.authentication.sso.oidc.entraid.views import (
+ _validate_ms_id_token,
+ )
+ from allauth.socialaccount.providers.oauth2.client import OAuth2Error
+
+ # JWKS resolves; jwt.decode returns claims for the WRONG tenant.
+ mock_jwt.PyJWKClient.return_value.get_signing_key_from_jwt.return_value = (
+ MagicMock(key="signing-key")
+ )
+ mock_jwt.decode.return_value = {
+ "tid": "ATTACKER-TENANT-UUID",
+ "nonce": "n",
+ }
+ # Real exception class so the inner try/except matches correctly.
+ import jwt as real_jwt
+ mock_jwt.InvalidTokenError = real_jwt.InvalidTokenError
+
+ with self.assertRaises(OAuth2Error) as ctx:
+ _validate_ms_id_token(
+ "fake.token",
+ audience="phase-console",
+ expected_tenant_id="REAL-TENANT-UUID",
+ expected_nonce="n",
+ )
+ self.assertIn("tenant mismatch", str(ctx.exception).lower())
+
+ @patch("ee.authentication.sso.oidc.entraid.views.jwt")
+ def test_accepts_token_from_correct_tenant(self, mock_jwt):
+ from ee.authentication.sso.oidc.entraid.views import (
+ _validate_ms_id_token,
+ )
+
+ mock_jwt.PyJWKClient.return_value.get_signing_key_from_jwt.return_value = (
+ MagicMock(key="signing-key")
+ )
+ mock_jwt.decode.return_value = {
+ "tid": "real-tenant-uuid",
+ "nonce": "n",
+ }
+ import jwt as real_jwt
+ mock_jwt.InvalidTokenError = real_jwt.InvalidTokenError
+
+ claims = _validate_ms_id_token(
+ "fake.token",
+ audience="phase-console",
+ expected_tenant_id="REAL-TENANT-UUID", # case-insensitive match
+ expected_nonce="n",
+ )
+ self.assertEqual(claims["tid"], "real-tenant-uuid")
+
+ def test_refuses_when_no_tenant_configured(self):
+ """Fail-closed when tenant config is missing."""
+ from ee.authentication.sso.oidc.entraid.views import (
+ _validate_ms_id_token,
+ )
+ from allauth.socialaccount.providers.oauth2.client import OAuth2Error
+
+ with self.assertRaises(OAuth2Error) as ctx:
+ _validate_ms_id_token(
+ "fake.token",
+ audience="phase-console",
+ expected_tenant_id=None,
+ expected_nonce="n",
+ )
+ self.assertIn("tenant configuration missing", str(ctx.exception).lower())
+
+
+class EntraIdTenantResolutionTest(unittest.TestCase):
+ """`_resolve_expected_tenant_id`: org config → fallback to env."""
+
+ @patch("ee.authentication.sso.oidc.entraid.views.os")
+ def test_falls_back_to_env_when_no_org_config_in_session(self, mock_os):
+ from ee.authentication.sso.oidc.entraid.views import (
+ _resolve_expected_tenant_id,
+ )
+ mock_os.getenv.return_value = "env-tenant-uuid"
+
+ request = MagicMock()
+ request.session = {} # No sso_org_config_id
+
+ self.assertEqual(_resolve_expected_tenant_id(request), "env-tenant-uuid")
+ mock_os.getenv.assert_called_with("ENTRA_ID_OIDC_TENANT_ID")
+
+ @patch("api.utils.sso.get_org_sso_config")
+ def test_reads_tenant_from_org_config_when_present(self, mock_get_org):
+ from ee.authentication.sso.oidc.entraid.views import (
+ _resolve_expected_tenant_id,
+ )
+ provider = MagicMock()
+ org_config = {"tenant_id": "org-tenant-uuid", "client_id": "x"}
+ mock_get_org.return_value = (provider, org_config)
+
+ request = MagicMock()
+ request.session = {"sso_org_config_id": "cfg-1"}
+
+ self.assertEqual(_resolve_expected_tenant_id(request), "org-tenant-uuid")
+ mock_get_org.assert_called_once_with("cfg-1")
+
+
+class CloudModeGuardRemovalTest(unittest.TestCase):
+ """Verify EE adapters no longer block on APP_HOST=cloud."""
+
+ @patch("ee.authentication.sso.oidc.entraid.views._resolve_expected_tenant_id")
+ @patch("ee.authentication.sso.oidc.entraid.views._validate_ms_id_token")
+ @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,
+ mock_validate_token, mock_resolve_tenant,
+ ):
+ from ee.authentication.sso.oidc.entraid.views import CustomMicrosoftGraphOAuth2Adapter
+
+ mock_settings.APP_HOST = "cloud"
+ mock_resolve_tenant.return_value = "00000000-0000-0000-0000-000000000abc"
+
+ # Mock a successful ID-token validation so the adapter proceeds.
+ mock_validate_token.return_value = {
+ "email": "test@example.com",
+ "nonce": "n",
+ }
+
+ # 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-access-token"
+ mock_token.id_token = "fake.id.token"
+
+ mock_request = MagicMock()
+ mock_request.session.get.return_value = "n"
+
+ mock_app = MagicMock()
+ mock_app.client_id = "phase-console"
+
+ # Should NOT raise OAuth2Error — cloud mode is no longer blocked
+ result = adapter.complete_login(mock_request, mock_app, mock_token)
+ self.assertIsNotNone(result)
+ # Validation must have been called with client_id, nonce, and
+ # the configured tenant_id pinning.
+ mock_validate_token.assert_called_once_with(
+ "fake.id.token",
+ audience="phase-console",
+ expected_tenant_id="00000000-0000-0000-0000-000000000abc",
+ expected_nonce="n",
+ )
+
+
+# ---------------------------------------------------------------------------
+# 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")
+
+ # The kwarg→org resolution is delegated to
+ # api.utils.access.org_resolution.resolve_org_id (tested independently
+ # with its own cache layers). Here we only verify that the middleware
+ # is plumbing kwargs through for each resource-ID shape that used to
+ # have a bespoke dispatch entry — anything that resolves to an org
+ # with require_sso=True must raise.
+
+ @patch("backend.graphene.middleware.resolve_org_id", return_value="org-1")
+ @patch("backend.graphene.middleware.Organisation")
+ def _assert_kwarg_blocks(self, kwarg_name, kwarg_value, mock_org_cls, _resolver):
+ 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):
+ mw.resolve(self._next, None, info, **{kwarg_name: kwarg_value})
+
+ def test_resolves_org_via_app_id(self):
+ self._assert_kwarg_blocks("app_id", "app-1")
+
+ def test_resolves_org_via_env_id(self):
+ self._assert_kwarg_blocks("env_id", "env-1")
+
+ def test_resolves_org_via_secret_id(self):
+ """Regression: secret_id was a middleware-bypass path before the
+ dispatch-table expansion. It must still block for SSO-enforced orgs
+ under the new auto-discovery resolver."""
+ self._assert_kwarg_blocks("secret_id", "sec-1")
+
+ def test_resolves_org_via_member_id(self):
+ self._assert_kwarg_blocks("member_id", "mem-1")
+
+ def test_resolves_org_via_service_account_id(self):
+ self._assert_kwarg_blocks("service_account_id", "sa-1")
+
+ def test_resolves_org_via_invite_id(self):
+ self._assert_kwarg_blocks("invite_id", "inv-1")
+
+ def test_resolves_org_via_provider_id(self):
+ """Regression: provider_id was NOT in the old dispatch table and
+ was a silent bypass for the SSO-downgrading mutations
+ (deleteOrganisationSsoProvider, updateOrganisationSsoProvider,
+ updateOrganisationSecurity). Auto-discovery covers it now."""
+ self._assert_kwarg_blocks("provider_id", "prov-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")
+
+ # Bare `id` / `ids` — model derived from the mutation's output
+ # type (or `org_resource_model` for primitive-returning mutations).
+
+ @patch("backend.graphene.middleware.resolve_via_model", return_value="org-1")
+ @patch("backend.graphene.middleware._model_for_mutation")
+ @patch("backend.graphene.middleware.Organisation")
+ def _assert_bare_id_blocks(
+ self,
+ model_name,
+ kwarg_name,
+ kwarg_value,
+ mock_org_cls,
+ mock_model_resolver,
+ mock_resolver,
+ ):
+ 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
+ mock_model_resolver.return_value = model_name
+
+ mw = OrgSSOEnforcementMiddleware()
+ info = self._make_info(session_auth_method="password")
+ with self.assertRaises(SSORequiredError):
+ mw.resolve(self._next, None, info, **{kwarg_name: kwarg_value})
+
+ def test_resolves_org_via_secret_model_bare_id(self):
+ self._assert_bare_id_blocks("Secret", "id", "sec-1")
+
+ def test_resolves_org_via_secret_model_bare_ids(self):
+ self._assert_bare_id_blocks("Secret", "ids", ["sec-1", "sec-2"])
+
+ def test_resolves_org_via_app_model_bare_id(self):
+ self._assert_bare_id_blocks("App", "id", "app-1")
+
+ def test_resolves_org_via_role_model_bare_id(self):
+ self._assert_bare_id_blocks("Role", "id", "role-1")
+
+ def test_resolves_org_via_policy_model_bare_id(self):
+ self._assert_bare_id_blocks("NetworkAccessPolicy", "id", "policy-1")
+
+ def test_unresolvable_mutation_bare_id_passes_through(self):
+ """No model resolvable → middleware passes through; per-mutation
+ permission checks remain the safety net."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ mw = OrgSSOEnforcementMiddleware()
+ info = self._make_info(session_auth_method="password")
+
+ with patch(
+ "backend.graphene.middleware._model_for_mutation",
+ return_value=None,
+ ):
+ result = mw.resolve(self._next, None, info, id="unknown-id")
+ self.assertEqual(result, "resolver_ran")
+
+ # `*_data` input objects — recurse into their nested *_id fields.
+
+ @patch("backend.graphene.middleware.resolve_org_id", return_value="org-1")
+ @patch("backend.graphene.middleware.Organisation")
+ def test_resolves_org_via_secret_data_env_id(
+ self, mock_org_cls, mock_resolver
+ ):
+ 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")
+
+ # Graphene InputObjectType subclasses dict, so we simulate it
+ # with a plain dict carrying the input fields.
+ secret_data = {"env_id": "env-1", "key": "DB_URL", "value": "..."}
+
+ with self.assertRaises(SSORequiredError):
+ mw.resolve(self._next, None, info, secret_data=secret_data)
+
+ @patch("backend.graphene.middleware.resolve_org_id", return_value="org-1")
+ @patch("backend.graphene.middleware.Organisation")
+ def test_resolves_org_via_secrets_data_list(
+ self, mock_org_cls, mock_resolver
+ ):
+ """List of input objects — recursion walks every element."""
+ 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")
+
+ secrets_data = [
+ {"key": "X", "value": "..."}, # no env_id (intentional skip)
+ {"env_id": "env-1", "key": "Y", "value": "..."},
+ ]
+
+ with self.assertRaises(SSORequiredError):
+ mw.resolve(self._next, None, info, secrets_data=secrets_data)
+
+ def test_input_with_no_resolvable_id_passes_through(self):
+ """Input with no `*_id` field → middleware passes through."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ mw = OrgSSOEnforcementMiddleware()
+ info = self._make_info(session_auth_method="password")
+
+ result = mw.resolve(
+ self._next, None, info, secret_data={"key": "X", "value": "..."}
+ )
+ self.assertEqual(result, "resolver_ran")
+
+
+class SSOEnforcementBypassTest(unittest.TestCase):
+ """SSO admin mutations carry `bypass_sso_enforcement = True` so a
+ password-Owner can recover from a self-imposed require_sso=True
+ lockout. Without this exemption, the only enabled provider can't
+ be deactivated and `--disable-enforcement` is the only recovery."""
+
+ def _info_for(self, field_name):
+ from backend.schema import schema
+ gql_schema = getattr(schema, "graphql_schema", schema)
+ info = MagicMock()
+ info.return_type = gql_schema.mutation_type.fields[field_name].type
+ info.field_name = field_name
+ return info
+
+ def _make_request(self, auth_method="password"):
+ info = MagicMock()
+ info.context.user = MagicMock(is_authenticated=True)
+ info.context.session = {"auth_method": auth_method}
+ return info
+
+ def test_update_sso_provider_bypasses_enforcement(self):
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertTrue(
+ _bypasses_sso_enforcement(self._info_for("updateOrganisationSsoProvider"))
+ )
+
+ def test_delete_sso_provider_bypasses_enforcement(self):
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertTrue(
+ _bypasses_sso_enforcement(self._info_for("deleteOrganisationSsoProvider"))
+ )
+
+ def test_update_organisation_security_bypasses_enforcement(self):
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertTrue(
+ _bypasses_sso_enforcement(self._info_for("updateOrganisationSecurity"))
+ )
+
+ def test_create_sso_provider_bypasses_enforcement(self):
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertTrue(
+ _bypasses_sso_enforcement(self._info_for("createOrganisationSsoProvider"))
+ )
+
+ def test_test_sso_provider_bypasses_enforcement(self):
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertTrue(
+ _bypasses_sso_enforcement(self._info_for("testOrganisationSsoProvider"))
+ )
+
+ def test_non_sso_mutation_does_not_bypass(self):
+ """Regression: only SSO admin mutations carry the bypass."""
+ from backend.graphene.middleware import _bypasses_sso_enforcement
+ self.assertFalse(
+ _bypasses_sso_enforcement(self._info_for("editSecret"))
+ )
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_password_owner_can_reach_locked_out_org_via_bypass(self, mock_org_cls):
+ """End-to-end: a password session against a require_sso=True
+ org reaches updateOrganisationSsoProvider despite the middleware
+ normally blocking that combination."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ # If the middleware resolved org and consulted the decision cache,
+ # this would yield (True, "acme") → SSORequiredError.
+ org = MagicMock(require_sso=True)
+ org.name = "acme"
+ org.id = "org-1"
+ mock_org_cls.objects.only.return_value.get.return_value = org
+
+ info = self._make_request(auth_method="password")
+ info.return_type = self._info_for(
+ "updateOrganisationSsoProvider"
+ ).return_type
+ info.field_name = "updateOrganisationSsoProvider"
+
+ ran = []
+
+ def _next(root, info, **kwargs):
+ ran.append(True)
+ return "ok"
+
+ mw = OrgSSOEnforcementMiddleware()
+ # `organisation_id` hits the direct-resolution fast path so the
+ # decision cache lookup would normally fire and block.
+ result = mw.resolve(_next, None, info, organisation_id="org-1")
+
+ self.assertEqual(result, "ok")
+ self.assertEqual(ran, [True])
+ # Bypass must short-circuit before any DB lookup.
+ mock_org_cls.objects.only.return_value.get.assert_not_called()
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_non_bypass_mutation_still_blocked_on_require_sso_org(
+ self, mock_org_cls
+ ):
+ """Regression: the bypass must not leak to non-SSO mutations."""
+ from backend.graphene.middleware import (
+ OrgSSOEnforcementMiddleware,
+ SSORequiredError,
+ )
+
+ org = MagicMock(require_sso=True)
+ org.name = "acme"
+ org.id = "org-1"
+ mock_org_cls.objects.only.return_value.get.return_value = org
+
+ info = self._make_request(auth_method="password")
+ info.return_type = self._info_for("editSecret").return_type
+ info.field_name = "editSecret"
+
+ mw = OrgSSOEnforcementMiddleware()
+ with self.assertRaises(SSORequiredError):
+ mw.resolve(lambda r, i, **k: "ok", None, info, organisation_id="org-1")
+
+
+class ModelForMutationDerivationTest(unittest.TestCase):
+ """`_model_for_mutation` derives the affected Django model from a
+ mutation's GraphQL return type, or from `org_resource_model`."""
+
+ def _gql_return_type(self, field_name):
+ from backend.schema import schema
+ gql_schema = getattr(schema, "graphql_schema", schema)
+ return gql_schema.mutation_type.fields[field_name].type
+
+ def test_derives_model_from_djangoobjecttype_output(self):
+ """editSecret returns SecretType → walks output, finds Secret."""
+ from backend.graphene.middleware import _model_for_mutation
+
+ info = MagicMock()
+ info.return_type = self._gql_return_type("editSecret")
+ self.assertEqual(_model_for_mutation(info), "Secret")
+
+ def test_explicit_org_resource_model_takes_precedence(self):
+ """readSecret returns ok=Boolean → reads `org_resource_model`."""
+ from backend.graphene.middleware import _model_for_mutation
+
+ info = MagicMock()
+ info.return_type = self._gql_return_type("readSecret")
+ self.assertEqual(_model_for_mutation(info), "Secret")
+
+ def test_returns_none_for_mutation_without_model_or_attribute(self):
+ """Primitive output + no attribute → None (don't guess)."""
+ from backend.graphene.middleware import _model_for_mutation
+ import graphene
+
+ class _DummyMutation(graphene.Mutation):
+ class Arguments:
+ id = graphene.ID(required=True)
+ ok = graphene.Boolean()
+ @classmethod
+ def mutate(cls, root, info, id):
+ return cls(ok=True)
+
+ class _Q(graphene.ObjectType):
+ hi = graphene.String()
+
+ class _M(graphene.ObjectType):
+ do_dummy = _DummyMutation.Field()
+
+ s = graphene.Schema(query=_Q, mutation=_M)
+ gql_schema = getattr(s, "graphql_schema", s)
+ info = MagicMock()
+ info.return_type = gql_schema.mutation_type.fields["doDummy"].type
+ self.assertIsNone(_model_for_mutation(info))
+
+
+# ---------------------------------------------------------------------------
+# 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).
+
+ Uses a password session so the middleware's SSO-session fast-path
+ doesn't short-circuit before the decision cache is consulted —
+ the whole point of these tests is to verify the cache layer, not
+ the fast-path."""
+
+ def __init__(self):
+ self.user = type("U", (), {"is_authenticated": True})()
+ self.session = {"auth_method": "password"}
+
+ def _make_info_with_real_request(self):
+ info = MagicMock()
+ info.context = self._StubRequest()
+ return info
+
+ def _next(self, root, info, **kwargs):
+ return "ran"
+
+ def setUp(self):
+ # The decision cache now has both a per-request L1 and a
+ # Redis-backed L2. Clear Redis (locmem in tests) between tests
+ # to prevent bleed.
+ from django.core.cache import cache
+ cache.clear()
+
+ # Use require_sso=False so the password session passes through — the
+ # caching behaviour is independent of the enforcement decision, and
+ # these tests aren't exercising the enforcement branch.
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_org_lookup_cached_across_calls(self, mock_org_cls):
+ """A single GraphQL document often pulls many org-scoped fields —
+ they must all share one Organisation lookup, not re-query each time."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ org = MagicMock(require_sso=False)
+ org.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, organisation_id="org-1")
+ mw.resolve(self._next, None, info, organisation_id="org-1")
+ mw.resolve(self._next, None, info, organisation_id="org-1")
+
+ self.assertEqual(
+ mock_org_cls.objects.only.return_value.get.call_count, 1
+ )
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_decision_cached_in_redis_across_requests(self, mock_org_cls):
+ """Second request against the same org must hit the Redis decision
+ cache, not re-query Postgres — that's the whole point of Level 1
+ Redis caching."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ org = MagicMock(require_sso=False)
+ org.name = "acme"
+ mock_org_cls.objects.only.return_value.get.return_value = org
+
+ mw = OrgSSOEnforcementMiddleware()
+
+ mw.resolve(self._next, None, self._make_info_with_real_request(),
+ organisation_id="org-1")
+ mw.resolve(self._next, None, self._make_info_with_real_request(),
+ organisation_id="org-1")
+
+ self.assertEqual(
+ mock_org_cls.objects.only.return_value.get.call_count, 1
+ )
+
+ @patch("backend.graphene.middleware.Organisation")
+ def test_decision_invalidate_clears_redis(self, mock_org_cls):
+ """invalidate_decision must drop the cache so the next request
+ re-reads require_sso from the DB (so e.g. toggling enforcement
+ takes effect immediately for other users, not after the 60s TTL)."""
+ from backend.graphene.middleware import OrgSSOEnforcementMiddleware
+
+ org = MagicMock(require_sso=False)
+ org.name = "acme"
+ mock_org_cls.objects.only.return_value.get.return_value = org
+
+ mw = OrgSSOEnforcementMiddleware()
+
+ mw.resolve(self._next, None, self._make_info_with_real_request(),
+ organisation_id="org-1")
+ OrgSSOEnforcementMiddleware.invalidate_decision("org-1")
+ mw.resolve(self._next, None, self._make_info_with_real_request(),
+ organisation_id="org-1")
+
+ self.assertEqual(
+ mock_org_cls.objects.only.return_value.get.call_count, 2
+ )
+
+
+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 ChangeAccountPasswordMutation, 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
+ ChangeAccountPasswordMutation 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("/graphql/", {})
+ # 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."""
+ 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()
+ # 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"
+ )
+ 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(
+ "api.views.sso.http_requests.request",
+ 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)
+
+
+class InviteAcceptanceEmailMatchTest(unittest.TestCase):
+ """Regression: accepting an organisation invite requires the caller's
+ authenticated email to match the invite's invitee_email. Otherwise
+ a leaked invite_id (forwarded email, URL history, log dump) could
+ be claimed by any authenticated account."""
+
+ def _make_info(self, user_email):
+ info = MagicMock()
+ user = MagicMock()
+ user.email = user_email
+ user.userId = "user-uuid"
+ info.context.user = user
+ return info
+
+ @patch("backend.graphene.mutations.organisation.user_is_org_member")
+ @patch("backend.graphene.mutations.organisation.OrganisationMemberInvite")
+ def test_mismatched_email_raises(self, mock_invite_cls, mock_is_member):
+ from backend.graphene.mutations.organisation import (
+ CreateOrganisationMemberMutation,
+ )
+ from graphql import GraphQLError
+
+ mock_is_member.return_value = False
+ mock_invite_cls.objects.filter.return_value.exists.return_value = True
+ invite = MagicMock()
+ invite.invitee_email = "alice@example.com"
+ mock_invite_cls.objects.get.return_value = invite
+
+ info = self._make_info(user_email="eve@attacker.com")
+
+ with self.assertRaises(GraphQLError) as cm:
+ CreateOrganisationMemberMutation.mutate(
+ None,
+ info,
+ org_id="org-1",
+ identity_key="k",
+ wrapped_keyring="wk",
+ wrapped_recovery="wr",
+ invite_id="inv-1",
+ )
+ self.assertIn("another user", str(cm.exception))
+
+ @patch("backend.graphene.mutations.organisation.user_is_org_member")
+ @patch("backend.graphene.mutations.organisation.OrganisationMemberInvite")
+ def test_cross_org_redemption_raises(
+ self, mock_invite_cls, mock_is_member
+ ):
+ """Regression: an invite to org A must not be redeemable to join
+ org B. Only the email check + org_id check together bind the
+ invite to a specific (user, org) pair."""
+ from backend.graphene.mutations.organisation import (
+ CreateOrganisationMemberMutation,
+ )
+ from graphql import GraphQLError
+
+ mock_is_member.return_value = False
+ mock_invite_cls.objects.filter.return_value.exists.return_value = True
+ invite = MagicMock()
+ invite.invitee_email = "alice@example.com"
+ invite.organisation_id = "org-A"
+ mock_invite_cls.objects.get.return_value = invite
+
+ info = self._make_info(user_email="alice@example.com")
+
+ with self.assertRaises(GraphQLError) as cm:
+ CreateOrganisationMemberMutation.mutate(
+ None,
+ info,
+ org_id="org-B",
+ identity_key="k",
+ wrapped_keyring="wk",
+ wrapped_recovery="wr",
+ invite_id="inv-1",
+ )
+ self.assertIn("does not match", str(cm.exception))
+
+ @patch("backend.graphene.mutations.organisation.user_is_org_member")
+ @patch("backend.graphene.mutations.organisation.OrganisationMemberInvite")
+ def test_email_match_is_case_insensitive(
+ self, mock_invite_cls, mock_is_member
+ ):
+ """Invite address 'Alice@Example.com' must match caller
+ 'alice@example.com'. Emails are case-insensitive."""
+ from backend.graphene.mutations.organisation import (
+ CreateOrganisationMemberMutation,
+ )
+ from graphql import GraphQLError
+
+ mock_is_member.return_value = False
+ mock_invite_cls.objects.filter.return_value.exists.return_value = True
+ invite = MagicMock()
+ invite.invitee_email = "Alice@Example.com"
+ mock_invite_cls.objects.get.return_value = invite
+
+ info = self._make_info(user_email="alice@example.com")
+
+ # Must NOT raise the "another user" error — it would proceed to
+ # run the rest of the mutation, which will fail on unmocked
+ # dependencies. We care here only that the email check passes.
+ try:
+ CreateOrganisationMemberMutation.mutate(
+ None,
+ info,
+ org_id="org-1",
+ identity_key="k",
+ wrapped_keyring="wk",
+ wrapped_recovery="wr",
+ invite_id="inv-1",
+ )
+ except GraphQLError as e:
+ self.assertNotIn("another user", str(e))
+ except Exception:
+ pass # downstream unmocked collaborators — fine
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_sso.py b/backend/tests/test_sso.py
new file mode 100644
index 000000000..478c86156
--- /dev/null
+++ b/backend/tests/test_sso.py
@@ -0,0 +1,688 @@
+"""Tests for SSO auth views (api.views.sso).
+
+Uses unittest.TestCase (no database required) — all Django ORM
+interactions are mocked so these tests run in CI without Postgres.
+"""
+
+import unittest
+from unittest.mock import patch, MagicMock
+from urllib.parse import urlparse, parse_qs
+
+from django.test import RequestFactory
+from django.contrib.sessions.middleware import SessionMiddleware
+
+from api.views.sso import (
+ auth_me,
+ SSOAuthorizeView,
+ SSOCallbackView,
+ SSO_PROVIDER_REGISTRY,
+ _get_callback_url,
+ _check_email_domain_allowed,
+ _safe_oidc_request,
+ _exchange_code_for_token,
+ _get_oidc_endpoints,
+ _get_or_create_social_app,
+ _complete_login_bypassing_allauth,
+)
+
+
+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()
+
+
+class AuthMeViewTest(unittest.TestCase):
+ """Tests for GET /auth/me/."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_unauthenticated_returns_error(self):
+ """Unauthenticated requests are rejected."""
+ request = self.factory.get("/auth/me/")
+ request.user = MagicMock()
+ request.user.is_authenticated = False
+ _add_session_to_request(request)
+
+ response = auth_me(request)
+ self.assertIn(response.status_code, [401, 403])
+
+ def test_returns_user_info_when_authenticated(self):
+ """Authenticated user gets their info back."""
+ request = self.factory.get("/auth/me/")
+ user = MagicMock()
+ user.is_authenticated = True
+ user.userId = "test-uuid"
+ user.email = "test@example.com"
+ user.full_name = ""
+ user.auth_method = "sso"
+ user.socialaccount_set.first.return_value = None
+ request.user = user
+ _add_session_to_request(request)
+
+ response = auth_me(request)
+ self.assertEqual(response.status_code, 200)
+
+ import json
+ data = json.loads(response.content)
+ self.assertEqual(data["email"], "test@example.com")
+ self.assertEqual(data["fullName"], "test@example.com") # falls back to email
+ self.assertIsNone(data["avatarUrl"])
+
+ def test_returns_social_account_data(self):
+ """Social account data (avatar, name) is returned for SSO users."""
+ request = self.factory.get("/auth/me/")
+ user = MagicMock()
+ user.is_authenticated = True
+ user.userId = "test-uuid"
+ user.email = "test@example.com"
+ user.full_name = ""
+ user.auth_method = "sso"
+
+ social_acc = MagicMock()
+ social_acc.extra_data = {
+ "name": "Test User",
+ "picture": "https://example.com/avatar.jpg",
+ }
+ user.socialaccount_set.first.return_value = social_acc
+ request.user = user
+ _add_session_to_request(request)
+
+ response = auth_me(request)
+ import json
+ data = json.loads(response.content)
+ self.assertEqual(data["fullName"], "Test User")
+ self.assertEqual(data["avatarUrl"], "https://example.com/avatar.jpg")
+
+ def test_github_avatar_url(self):
+ """GitHub uses avatar_url key."""
+ request = self.factory.get("/auth/me/")
+ user = MagicMock()
+ user.is_authenticated = True
+ user.userId = "test-uuid"
+ user.email = "test@example.com"
+ user.full_name = ""
+ user.auth_method = "sso"
+
+ social_acc = MagicMock()
+ social_acc.extra_data = {
+ "name": "GH User",
+ "avatar_url": "https://github.com/avatar.jpg",
+ }
+ user.socialaccount_set.first.return_value = social_acc
+ request.user = user
+ _add_session_to_request(request)
+
+ response = auth_me(request)
+ import json
+ data = json.loads(response.content)
+ self.assertEqual(data["avatarUrl"], "https://github.com/avatar.jpg")
+
+ def test_entra_id_photo(self):
+ """Microsoft Entra ID uses photo key."""
+ request = self.factory.get("/auth/me/")
+ user = MagicMock()
+ user.is_authenticated = True
+ user.userId = "test-uuid"
+ user.email = "test@example.com"
+ user.full_name = ""
+ user.auth_method = "sso"
+
+ social_acc = MagicMock()
+ social_acc.extra_data = {
+ "name": "Entra User",
+ "photo": "https://graph.microsoft.com/photo.jpg",
+ }
+ user.socialaccount_set.first.return_value = social_acc
+ request.user = user
+ _add_session_to_request(request)
+
+ response = auth_me(request)
+ import json
+ data = json.loads(response.content)
+ self.assertEqual(data["avatarUrl"], "https://graph.microsoft.com/photo.jpg")
+
+
+class SSOAuthorizeViewTest(unittest.TestCase):
+ """Tests for GET /auth/sso//authorize/."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_unknown_provider_returns_404(self):
+ """Requesting an unknown provider returns a 404."""
+ 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)
+ parsed = urlparse(response.url)
+ params = parse_qs(parsed.query)
+
+ self.assertEqual(parsed.scheme, "https")
+ self.assertEqual(parsed.hostname, "idp.example.com")
+ self.assertEqual(params["client_id"][0], "test-client-id")
+ self.assertEqual(params["response_type"][0], "code")
+ self.assertIn("state", 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 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.assertEqual(request.session["sso_provider"], "test-provider")
+
+ parsed = urlparse(response.url)
+ params = parse_qs(parsed.query)
+ self.assertEqual(params["state"][0], request.session["sso_state"])
+
+ @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_preserves_callback_url(self):
+ """callbackUrl query param is stored in session."""
+ request = self.factory.get("/auth/sso/test-provider/authorize/?callbackUrl=/team/settings")
+ _add_session_to_request(request)
+
+ view = SSOAuthorizeView()
+ view.get(request, "test-provider")
+
+ self.assertEqual(request.session["sso_return_to"], "/team/settings")
+
+ @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.sso._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)
+
+ 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.sso._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(unittest.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_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_encoded_error(self):
+ """Provider returning an error redirects with URL-encoded description."""
+ request = self.factory.get(
+ "/auth/sso/google/callback/?error=access_denied&error_description=User+denied+access"
+ )
+ _add_session_to_request(request)
+
+ view = SSOCallbackView()
+ response = view.get(request, "google")
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("error=", response.url)
+ self.assertNotIn(" ", response.url)
+
+ def test_unknown_provider_redirects_with_error(self):
+ """Unknown provider in callback redirects to login."""
+ 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)
+
+
+class DomainWhitelistTest(unittest.TestCase):
+ """Tests for email domain whitelist enforcement."""
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", [])
+ def test_no_whitelist_allows_all(self):
+ self.assertTrue(_check_email_domain_allowed("user@anything.com"))
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", ["acme.com", "example.org"])
+ def test_allowed_domain_passes(self):
+ self.assertTrue(_check_email_domain_allowed("user@acme.com"))
+ self.assertTrue(_check_email_domain_allowed("admin@example.org"))
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", ["acme.com"])
+ def test_blocked_domain_fails(self):
+ self.assertFalse(_check_email_domain_allowed("user@other.com"))
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", ["acme.com"])
+ def test_case_insensitive(self):
+ self.assertTrue(_check_email_domain_allowed("user@ACME.COM"))
+
+
+class CallbackUrlTest(unittest.TestCase):
+ """Tests for callback URL generation."""
+
+ @patch("api.views.sso.FRONTEND_URL", "https://app.phase.dev")
+ def test_callback_url_uses_frontend_url(self):
+ """Callback URL uses the frontend base URL for legacy compat."""
+ url = _get_callback_url("google")
+ self.assertEqual(url, "https://app.phase.dev/api/auth/callback/google")
+
+
+class ProviderRegistryTest(unittest.TestCase):
+ """Tests for the SSO provider registry."""
+
+ def test_registry_structure(self):
+ """Registry entries have required fields."""
+ 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)
+
+
+class SSRFGuardTest(unittest.TestCase):
+ """Regressions for the SSRF guards on OIDC discovery and token
+ exchange. On cloud, URLs must be passed through validate_url_is_safe
+ and redirects must be disabled to defeat 30x pivots."""
+
+ @patch("api.views.sso.settings")
+ @patch("api.views.sso.validate_url_is_safe")
+ @patch("api.views.sso.http_requests.request")
+ def test_safe_oidc_request_validates_on_cloud(
+ self, mock_request, mock_validate, mock_settings
+ ):
+ mock_settings.APP_HOST = "cloud"
+ _safe_oidc_request("GET", "https://issuer.example.com/foo")
+ mock_validate.assert_called_once_with("https://issuer.example.com/foo")
+ # allow_redirects must be False regardless of caller
+ self.assertFalse(mock_request.call_args.kwargs.get("allow_redirects"))
+
+ @patch("api.views.sso.settings")
+ @patch("api.views.sso.validate_url_is_safe")
+ @patch("api.views.sso.http_requests.request")
+ def test_safe_oidc_request_skips_validation_self_hosted(
+ self, mock_request, mock_validate, mock_settings
+ ):
+ mock_settings.APP_HOST = "self"
+ _safe_oidc_request("GET", "https://internal.corp/foo")
+ mock_validate.assert_not_called()
+ self.assertFalse(mock_request.call_args.kwargs.get("allow_redirects"))
+
+ @patch("api.views.sso.settings")
+ @patch("api.views.sso.validate_url_is_safe")
+ def test_safe_oidc_request_raises_on_private_ip(
+ self, mock_validate, mock_settings
+ ):
+ from django.core.exceptions import ValidationError
+
+ mock_settings.APP_HOST = "cloud"
+ mock_validate.side_effect = ValidationError("private IP")
+ with self.assertRaises(ValueError):
+ _safe_oidc_request("GET", "http://169.254.169.254/latest/meta-data/")
+
+ @patch("api.views.sso.settings")
+ @patch("api.views.sso.http_requests.request")
+ @patch("api.views.sso.validate_url_is_safe")
+ def test_discovery_rejects_unsafe_token_endpoint(
+ self, mock_validate, mock_request, mock_settings
+ ):
+ """A malicious discovery doc returning an internal token_endpoint
+ must be rejected even if the issuer URL itself was safe."""
+ from django.core.exceptions import ValidationError
+
+ mock_settings.APP_HOST = "cloud"
+
+ def validate_side_effect(url):
+ if "169.254" in url:
+ raise ValidationError("private IP")
+
+ mock_validate.side_effect = validate_side_effect
+
+ resp = MagicMock()
+ resp.json.return_value = {
+ "authorization_endpoint": "https://issuer.example.com/authorize",
+ "token_endpoint": "http://169.254.169.254/token",
+ }
+ mock_request.return_value = resp
+
+ # Clear any stale cache so the fetch actually runs
+ from api.views import sso as sso_mod
+ sso_mod._oidc_cache.clear()
+
+ endpoints = _get_oidc_endpoints("https://issuer.example.com")
+ self.assertIsNone(endpoints)
+
+ @patch("api.views.sso.SocialApp")
+ def test_org_level_socialapp_isolated_per_client_id(self, mock_social_app):
+ """Two orgs configuring the same provider_id must get distinct
+ SocialApp rows — their (provider, client_id) discriminator
+ prevents Org B's secret from overwriting Org A's.
+ """
+ existing = MagicMock()
+ existing.client_id = "org-a-client"
+ existing.secret = "org-a-secret"
+
+ # Org A already has a SocialApp; Org B looks up by a different
+ # client_id and misses.
+ def filter_side_effect(**kwargs):
+ qs = MagicMock()
+ if kwargs.get("client_id") == "org-a-client":
+ qs.first.return_value = existing
+ else:
+ qs.first.return_value = None
+ return qs
+
+ mock_social_app.objects.filter.side_effect = filter_side_effect
+ mock_social_app.objects.create.return_value = MagicMock(
+ client_id="org-b-client"
+ )
+
+ # Org A lookup — returns existing, no create
+ config_a = {
+ "provider_id": "okta-oidc",
+ "client_id": "org-a-client",
+ "client_secret": "org-a-secret",
+ }
+ _get_or_create_social_app(config_a, org_config_id="org-a-config")
+ mock_social_app.objects.create.assert_not_called()
+
+ # Org B lookup — misses, creates a NEW row (doesn't touch Org A)
+ config_b = {
+ "provider_id": "okta-oidc",
+ "client_id": "org-b-client",
+ "client_secret": "org-b-secret",
+ }
+ _get_or_create_social_app(config_b, org_config_id="org-b-config")
+ mock_social_app.objects.create.assert_called_once()
+ create_kwargs = mock_social_app.objects.create.call_args.kwargs
+ self.assertEqual(create_kwargs["client_id"], "org-b-client")
+ self.assertEqual(create_kwargs["secret"], "org-b-secret")
+ # Org A's existing row must not have been mutated
+ self.assertEqual(existing.secret, "org-a-secret")
+
+ @patch("api.views.sso.SocialApp")
+ def test_instance_level_socialapp_single_row_per_provider(
+ self, mock_social_app
+ ):
+ """Instance-level flow keeps the single-row behaviour: credential
+ rotation in env vars updates the same SocialApp row."""
+ app = MagicMock(client_id="old-id", secret="old-secret")
+ mock_social_app.objects.get_or_create.return_value = (app, False)
+
+ config = {
+ "provider_id": "google",
+ "client_id": "new-id",
+ "client_secret": "new-secret",
+ }
+ _get_or_create_social_app(config) # no org_config_id
+ mock_social_app.objects.get_or_create.assert_called_once()
+ # Keys rotated → existing row gets updated
+ self.assertEqual(app.client_id, "new-id")
+ self.assertEqual(app.secret, "new-secret")
+ app.save.assert_called_once()
+
+ @patch("api.views.sso.settings")
+ @patch("api.views.sso.http_requests.request")
+ def test_exchange_code_uses_safe_request(self, mock_request, mock_settings):
+ """Token exchange must go through _safe_oidc_request so the
+ POST can't be redirected to an internal host."""
+ mock_settings.APP_HOST = "self" # skip IP validation, just check redirects
+ resp = MagicMock()
+ resp.json.return_value = {"access_token": "abc"}
+ mock_request.return_value = resp
+
+ _exchange_code_for_token(
+ "https://idp.example.com/token",
+ {"code": "x", "client_id": "cid", "client_secret": "csecret"},
+ "client_secret_post",
+ "cid",
+ "csecret",
+ )
+ self.assertEqual(mock_request.call_args.args[0], "POST")
+ self.assertFalse(mock_request.call_args.kwargs.get("allow_redirects"))
+
+
+class SocialAccountLookupTest(unittest.TestCase):
+ """Regression: the SSO login flow resolves identity by (provider, uid)
+ first, only falling back to email lookup for brand-new IdP identities.
+ Looking up CustomUser by the current IdP email would orphan an
+ existing user whose IdP-side email has since changed, taking all of
+ their OrganisationMembers with it.
+ """
+
+ def _make_social_login(self, provider, uid, email):
+ sl = MagicMock()
+ sl.account.provider = provider
+ sl.account.uid = uid
+ sl.account.extra_data = {"email": email, "email_verified": True}
+ sl.user.email = email
+ return sl
+
+ def _make_token(self):
+ token = MagicMock()
+ token.token = "access-token"
+ token.token_secret = ""
+ token.app = MagicMock()
+ return token
+
+ @patch("api.views.sso.login")
+ @patch("api.views.sso.SocialToken")
+ @patch("api.views.sso.SocialAccount")
+ @patch("api.views.sso.get_user_model")
+ def test_idp_email_change_reuses_existing_user(
+ self, mock_get_user_model, mock_sa_cls, mock_token_cls, mock_login
+ ):
+ """IdP identity (provider, uid) already linked → use that user
+ even when the incoming email no longer matches the stored
+ CustomUser.email. Under the buggy lookup-by-email-first path
+ this would create a duplicate CustomUser.
+ """
+ original_user = MagicMock()
+ original_user.email = "bob@old.com"
+ existing_sa = MagicMock()
+ existing_sa.user = original_user
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.return_value = existing_sa
+
+ User = MagicMock()
+ mock_get_user_model.return_value = User
+
+ request = MagicMock()
+ social_login = self._make_social_login("google", "G1", "bob@new.com")
+
+ _complete_login_bypassing_allauth(request, social_login, self._make_token())
+
+ mock_sa_cls.objects.get.assert_called_once_with(
+ provider="google", uid="G1"
+ )
+ User.objects.get.assert_not_called()
+ User.objects.create_user.assert_not_called()
+ mock_sa_cls.objects.create.assert_not_called()
+ existing_sa.save.assert_called_once()
+ mock_login.assert_called_once_with(request, original_user)
+
+ @patch("api.views.sso.login")
+ @patch("api.views.sso.SocialToken")
+ @patch("api.views.sso.SocialAccount")
+ @patch("api.views.sso.get_user_model")
+ def test_new_idp_identity_refuses_silent_link_to_existing_email(
+ self, mock_get_user_model, mock_sa_cls, mock_token_cls, mock_login
+ ):
+ """Regression: org-admin-controlled IdP could otherwise hijack
+ any invited email. Silent linking now refused; opt-in only."""
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist
+ # Critical: existing user has NO SocialAccount for this provider.
+ mock_sa_cls.objects.filter.return_value.exists.return_value = False
+
+ existing_user = MagicMock()
+ existing_user.email = "alice@example.com"
+ User = MagicMock()
+ User.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ User.objects.get.return_value = existing_user
+ mock_get_user_model.return_value = User
+
+ request = MagicMock()
+ social_login = self._make_social_login(
+ "google", "G-NEW", "alice@example.com"
+ )
+
+ with self.assertRaises(ValueError) as ctx:
+ _complete_login_bypassing_allauth(
+ request, social_login, self._make_token()
+ )
+
+ self.assertIn("already exists", str(ctx.exception).lower())
+ # No user created, no SocialAccount linked, no session issued.
+ User.objects.create_user.assert_not_called()
+ mock_sa_cls.objects.create.assert_not_called()
+ mock_login.assert_not_called()
+
+ @patch("api.views.sso.login")
+ @patch("api.views.sso.SocialToken")
+ @patch("api.views.sso.SocialAccount")
+ @patch("api.views.sso.get_user_model")
+ def test_new_idp_identity_and_new_email_creates_user(
+ self, mock_get_user_model, mock_sa_cls, mock_token_cls, mock_login
+ ):
+ """SocialAccount miss + user-by-email miss → create fresh user."""
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist
+
+ User = MagicMock()
+ User.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ User.objects.get.side_effect = User.DoesNotExist
+ new_user = MagicMock()
+ User.objects.create_user.return_value = new_user
+ mock_get_user_model.return_value = User
+
+ request = MagicMock()
+ social_login = self._make_social_login(
+ "google", "G2", "newcomer@example.com"
+ )
+
+ _complete_login_bypassing_allauth(request, social_login, self._make_token())
+
+ User.objects.create_user.assert_called_once_with(
+ username="newcomer@example.com",
+ email="newcomer@example.com",
+ password=None,
+ )
+ mock_sa_cls.objects.create.assert_called_once()
+ mock_login.assert_called_once_with(request, new_user)
diff --git a/backend/tests/test_sso_providers.py b/backend/tests/test_sso_providers.py
new file mode 100644
index 000000000..fdf1fde53
--- /dev/null
+++ b/backend/tests/test_sso_providers.py
@@ -0,0 +1,1553 @@
+"""
+Happy-path tests for the SSO callback flow with each provider.
+
+Each provider test exercises the full SSOCallbackView.get() path with:
+- Realistic token-exchange responses matching the provider's documented format
+- Realistic adapter output (SocialLogin with provider-specific extra_data)
+- User creation / linking via _complete_login_bypassing_allauth
+- Session cleanup and redirect verification
+
+All Django ORM operations and external HTTP calls are mocked — no database
+or network required. Mock data shapes are derived from each provider's
+public API documentation and real-world responses.
+"""
+
+import json
+import unittest
+from unittest.mock import patch, MagicMock
+from urllib.parse import urlparse, parse_qs
+
+from django.test import RequestFactory
+from django.contrib.sessions.middleware import SessionMiddleware
+
+from api.views.sso import (
+ auth_me,
+ SSOCallbackView,
+ SSO_PROVIDER_REGISTRY,
+ _complete_login_bypassing_allauth,
+)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _add_session(request):
+ """Attach a working session to a RequestFactory-produced request."""
+ middleware = SessionMiddleware(lambda req: None)
+ middleware.process_request(request)
+ request.session.save()
+
+
+def _make_social_login(extra_data, uid, provider_id, email=None):
+ """Build a mock SocialLogin with the shape adapters produce."""
+ sl = MagicMock()
+ sl.account.extra_data = extra_data
+ sl.account.uid = uid
+ sl.account.provider = provider_id
+ sl.state = {}
+ sl.token = None
+
+ # .user mirrors what sociallogin_from_response populates
+ sl.user = MagicMock()
+ sl.user.email = email or extra_data.get("email") or extra_data.get("mail") or ""
+ return sl
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# Provider Fixtures — realistic shapes from each IdP
+# ═══════════════════════════════════════════════════════════════════════════
+
+PROVIDERS = {}
+
+# --- Google OAuth2 --------------------------------------------------------
+
+PROVIDERS["google"] = {
+ "registry": {
+ "client_id": "123456789-abc.apps.googleusercontent.com",
+ "client_secret": "GOCSPX-test-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"},
+ },
+ "token_response": {
+ "access_token": "ya29.a0AfH6SMBxxxxxxxxxxxxx",
+ "expires_in": 3599,
+ "scope": "openid https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
+ "token_type": "Bearer",
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.fake-payload.fake-sig",
+ },
+ # Decoded id_token payload — this is what the Google adapter puts into
+ # extra_data after jwt.decode().
+ "extra_data": {
+ "sub": "109876543210987654321",
+ "email": "alice@gmail.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "given_name": "Alice",
+ "family_name": "Johnson",
+ "picture": "https://lh3.googleusercontent.com/a/ACg8ocK=s96-c",
+ "aud": "123456789-abc.apps.googleusercontent.com",
+ "iss": "https://accounts.google.com",
+ "iat": 1714000000,
+ "exp": 1714003600,
+ },
+ "uid": "109876543210987654321", # Google uses 'sub'
+ "expected_email": "alice@gmail.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://lh3.googleusercontent.com/a/ACg8ocK=s96-c",
+ "avatar_key": "picture",
+}
+
+# --- GitHub OAuth2 --------------------------------------------------------
+
+PROVIDERS["github"] = {
+ "registry": {
+ "client_id": "Iv1.abcdef1234567890",
+ "client_secret": "ghsecret_xxxxxxxxxxxxxxxxxxxxxxxx",
+ "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",
+ },
+ "token_response": {
+ # GitHub returns a flat token response — no id_token, no expiry
+ "access_token": "gho_16C7e42F292c6912E7710c838347Ae178B4a",
+ "token_type": "bearer",
+ "scope": "user:email,read:user",
+ },
+ # From GET /user — GitHub returns the full public profile.
+ "extra_data": {
+ "id": 12345678,
+ "login": "alicejohnson",
+ "node_id": "MDQ6VXNlcjEyMzQ1Njc4",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "gravatar_id": "",
+ "url": "https://api.github.com/users/alicejohnson",
+ "html_url": "https://github.com/alicejohnson",
+ "type": "User",
+ "site_admin": False,
+ "name": "Alice Johnson",
+ "company": "Phase",
+ "blog": "https://alice.dev",
+ "location": "San Francisco, CA",
+ "email": "alice@github.com",
+ "hireable": None,
+ "bio": "Building the future",
+ "twitter_username": "alicejohnson",
+ "public_repos": 42,
+ "public_gists": 5,
+ "followers": 250,
+ "following": 30,
+ "created_at": "2018-06-15T10:30:00Z",
+ "updated_at": "2026-03-01T08:00:00Z",
+ },
+ "uid": "12345678", # GitHub uses 'id' (numeric)
+ "expected_email": "alice@github.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "avatar_key": "avatar_url",
+}
+
+# --- GitHub (email from /user/emails) -------------------------------------
+# Edge case: GitHub profile has no email; adapter falls back to /user/emails.
+
+PROVIDERS["github-no-primary-email"] = {
+ "registry": PROVIDERS["github"]["registry"],
+ "token_response": PROVIDERS["github"]["token_response"],
+ "extra_data": {
+ **PROVIDERS["github"]["extra_data"],
+ "email": "alice-fallback@github.com", # adapter resolved from /user/emails
+ },
+ "uid": "12345678",
+ "expected_email": "alice-fallback@github.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://avatars.githubusercontent.com/u/12345678?v=4",
+ "avatar_key": "avatar_url",
+ "skip_callback_test": True, # variant, tested separately
+}
+
+# --- GitLab OAuth2 --------------------------------------------------------
+
+PROVIDERS["gitlab"] = {
+ "registry": {
+ "client_id": "abcdef1234567890abcdef1234567890abcdef12",
+ "client_secret": "gloas-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+ "authorize_url": "https://gitlab.com/oauth/authorize",
+ "token_url": "https://gitlab.com/oauth/token",
+ "scopes": "read_user",
+ "adapter_module": "api.authentication.adapters.gitlab",
+ "adapter_class": "CustomGitLabOAuth2Adapter",
+ "provider_id": "gitlab",
+ "token_auth_method": "client_secret_post",
+ },
+ "token_response": {
+ "access_token": "glpat-xxxxxxxxxxxxxxxxxxxx",
+ "token_type": "Bearer",
+ "expires_in": 7200,
+ "refresh_token": "xxxxxxxxxxxxxxxxxxxx",
+ "scope": "read_user",
+ "created_at": 1714000000,
+ },
+ # From GET /api/v4/user
+ "extra_data": {
+ "id": 7654321,
+ "username": "alicejohnson",
+ "name": "Alice Johnson",
+ "state": "active",
+ "locked": False,
+ "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/7654321/avatar.png",
+ "web_url": "https://gitlab.com/alicejohnson",
+ "created_at": "2020-01-15T10:00:00.000Z",
+ "bio": "",
+ "location": "",
+ "public_email": "alice@gitlab.com",
+ "skype": "",
+ "linkedin": "",
+ "twitter": "",
+ "discord": "",
+ "website_url": "",
+ "organization": "Phase",
+ "job_title": "",
+ "pronouns": None,
+ "bot": False,
+ "work_information": None,
+ "local_time": None,
+ "last_sign_in_at": "2026-04-01T12:00:00.000Z",
+ "confirmed_at": "2020-01-15T10:05:00.000Z",
+ "last_activity_on": "2026-04-10",
+ "email": "alice@gitlab.com",
+ "theme_id": 1,
+ "color_scheme_id": 1,
+ "projects_limit": 100000,
+ "current_sign_in_at": "2026-04-10T09:00:00.000Z",
+ "identities": [],
+ "can_create_group": True,
+ "can_create_project": True,
+ "two_factor_enabled": True,
+ "external": False,
+ "private_profile": False,
+ "commit_email": "alice@gitlab.com",
+ },
+ "uid": "7654321", # GitLab uses 'id' (numeric)
+ "expected_email": "alice@gitlab.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://gitlab.com/uploads/-/system/user/avatar/7654321/avatar.png",
+ "avatar_key": "avatar_url",
+}
+
+# --- GitHub Enterprise ----------------------------------------------------
+
+PROVIDERS["github-enterprise"] = {
+ "registry": {
+ "client_id": "ghe-client-id-xxxxx",
+ "client_secret": "ghe-client-secret-xxxxx",
+ "authorize_url": "https://github.corp.example.com/login/oauth/authorize",
+ "token_url": "https://github.corp.example.com/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",
+ },
+ "token_response": {
+ "access_token": "gho_ent_xxxxxxxxxxxxxxxxxxxxxxxx",
+ "token_type": "bearer",
+ "scope": "user:email,read:user",
+ },
+ # Same shape as github.com /user but from enterprise instance
+ "extra_data": {
+ "id": 101,
+ "login": "ajohnson",
+ "node_id": "MDQ6VXNlcjEwMQ==",
+ "avatar_url": "https://github.corp.example.com/avatars/u/101",
+ "url": "https://github.corp.example.com/api/v3/users/ajohnson",
+ "html_url": "https://github.corp.example.com/ajohnson",
+ "type": "User",
+ "site_admin": False,
+ "name": "Alice Johnson",
+ "email": "alice@corp.example.com",
+ "company": "Example Corp",
+ "created_at": "2022-01-01T00:00:00Z",
+ "updated_at": "2026-04-01T00:00:00Z",
+ },
+ "uid": "101",
+ "expected_email": "alice@corp.example.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://github.corp.example.com/avatars/u/101",
+ "avatar_key": "avatar_url",
+}
+
+# --- Microsoft Entra ID (OIDC) -------------------------------------------
+# Uses Microsoft Graph /me — unique field names: displayName, mail, surName.
+# Email often comes from the id_token JWT, not the Graph API.
+
+PROVIDERS["entra-id-oidc"] = {
+ "registry": {
+ "client_id": "abcdef01-2345-6789-abcd-ef0123456789",
+ "client_secret": "entra-secret-xxxxxxxx",
+ "issuer": "https://login.microsoftonline.com/tenant-id/v2.0",
+ "scopes": "openid profile email User.Read",
+ "adapter_module": "ee.authentication.sso.oidc.entraid.views",
+ "adapter_class": "CustomMicrosoftGraphOAuth2Adapter",
+ "provider_id": "microsoft",
+ "token_auth_method": "client_secret_post",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.graph-access-token",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": "openid profile email User.Read",
+ "refresh_token": "0.AAAA-refresh-token-xxx",
+ "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.entra-id-token",
+ },
+ # Composite: Graph /me response enriched by adapter (adds "name" field).
+ # Note: Microsoft uses "mail" not "email", "displayName" not "name".
+ "extra_data": {
+ "id": "87654321-4321-4321-4321-210987654321",
+ "displayName": "Alice Johnson",
+ "givenName": "Alice",
+ "surName": "Johnson",
+ "mail": "alice@contoso.com",
+ "userPrincipalName": "alice@contoso.com",
+ "jobTitle": "Senior Engineer",
+ "officeLocation": "Building 42",
+ "mobilePhone": None,
+ "businessPhones": ["+1 555-0100"],
+ "preferredLanguage": "en-US",
+ # Adapter constructs this from displayName
+ "name": "Alice Johnson",
+ },
+ "uid": "87654321-4321-4321-4321-210987654321", # Entra uses Graph 'id'
+ "expected_email": "alice@contoso.com",
+ "expected_name": "Alice Johnson",
+ # Entra ID doesn't return a photo URL in the Graph /me response.
+ # The auth_me view would check extra.get("photo") which is absent.
+ "expected_avatar": None,
+ "avatar_key": "photo",
+}
+
+# --- Entra ID edge case: email from JWT preferred_username ----------------
+# When mail is null in Graph response, adapter falls back to JWT claims.
+
+PROVIDERS["entra-id-oidc-upn-fallback"] = {
+ "registry": PROVIDERS["entra-id-oidc"]["registry"],
+ "token_response": PROVIDERS["entra-id-oidc"]["token_response"],
+ "extra_data": {
+ **PROVIDERS["entra-id-oidc"]["extra_data"],
+ "mail": None, # Some tenants don't populate mail
+ },
+ "uid": "87654321-4321-4321-4321-210987654321",
+ # The adapter falls back to preferred_username from the JWT
+ "expected_email": "alice@contoso.com", # from userPrincipalName / JWT
+ "expected_name": "Alice Johnson",
+ "expected_avatar": None,
+ "avatar_key": "photo",
+ "skip_callback_test": True,
+}
+
+# --- Google OIDC ----------------------------------------------------------
+# Standard OIDC claims from id_token or userinfo endpoint.
+
+PROVIDERS["google-oidc"] = {
+ "registry": {
+ "client_id": "123456789-oidc.apps.googleusercontent.com",
+ "client_secret": "GOCSPX-oidc-test-secret",
+ "issuer": "https://accounts.google.com",
+ "scopes": "openid email profile",
+ "adapter_module": "ee.authentication.sso.oidc.util.google.views",
+ "adapter_class": "GoogleOpenIDConnectAdapter",
+ "provider_id": "google-oidc",
+ "token_auth_method": "client_secret_post",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "ya29.oidc-access-token",
+ "expires_in": 3599,
+ "scope": "openid email profile",
+ "token_type": "Bearer",
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.google-oidc-id-token",
+ },
+ # Decoded id_token / userinfo — standard OIDC claims
+ "extra_data": {
+ "sub": "109876543210987654321",
+ "email": "alice@company.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "picture": "https://lh3.googleusercontent.com/a/photo-oidc",
+ "given_name": "Alice",
+ "family_name": "Johnson",
+ "locale": "en",
+ "iss": "https://accounts.google.com",
+ "aud": "123456789-oidc.apps.googleusercontent.com",
+ "iat": 1714000000,
+ "exp": 1714003600,
+ },
+ "uid": "109876543210987654321", # OIDC uses 'sub'
+ "expected_email": "alice@company.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": "https://lh3.googleusercontent.com/a/photo-oidc",
+ "avatar_key": "picture",
+}
+
+# --- JumpCloud OIDC -------------------------------------------------------
+
+PROVIDERS["jumpcloud-oidc"] = {
+ "registry": {
+ "client_id": "jumpcloud-client-id-xxxxx",
+ "client_secret": "jumpcloud-client-secret-xxxxx",
+ "issuer": "https://oauth.id.jumpcloud.com",
+ "scopes": "openid email profile",
+ "adapter_module": "ee.authentication.sso.oidc.util.jumpcloud.views",
+ "adapter_class": "JumpCloudOpenIDConnectAdapter",
+ "provider_id": "jumpcloud-oidc",
+ "token_auth_method": "client_secret_post",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "jc-access-token-xxxxxxxxxxxx",
+ "expires_in": 3600,
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.jumpcloud-id-token",
+ "scope": "openid email profile",
+ "token_type": "bearer",
+ },
+ # JumpCloud userinfo / decoded id_token
+ "extra_data": {
+ "sub": "5f8b3e7c1a2b3c4d5e6f7890",
+ "email": "alice@jumpcloud-org.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "given_name": "Alice",
+ "family_name": "Johnson",
+ "picture": None, # JumpCloud often omits picture
+ },
+ "uid": "5f8b3e7c1a2b3c4d5e6f7890",
+ "expected_email": "alice@jumpcloud-org.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": None,
+ "avatar_key": "picture",
+}
+
+# --- Okta OIDC ------------------------------------------------------------
+# Uses client_secret_basic (HTTP Basic auth on token endpoint).
+
+PROVIDERS["okta-oidc"] = {
+ "registry": {
+ "client_id": "0oaxxxxxxxxxxxxxxxx",
+ "client_secret": "okta-secret-xxxxxxxxxxxxxxxxxxxxxxxx",
+ "issuer": "https://dev-12345.okta.com",
+ "scopes": "openid email profile",
+ "adapter_module": "ee.authentication.sso.oidc.okta.views",
+ "adapter_class": "OktaOpenIDConnectAdapter",
+ "provider_id": "okta-oidc",
+ "token_auth_method": "client_secret_basic",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "eyJraWQiOiJva3RhLWFjY2Vzcy10b2tlbiJ9.xxx",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "scope": "openid email profile",
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.okta-id-token",
+ },
+ # Okta userinfo / decoded id_token — standard OIDC with Okta extras
+ "extra_data": {
+ "sub": "00u1abcdefghijklmno",
+ "email": "alice@okta-org.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "given_name": "Alice",
+ "family_name": "Johnson",
+ "preferred_username": "alice@okta-org.com",
+ "locale": "en-US",
+ "zoneinfo": "America/Los_Angeles",
+ "updated_at": 1714000000,
+ },
+ "uid": "00u1abcdefghijklmno",
+ "expected_email": "alice@okta-org.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": None, # Okta doesn't return picture by default
+ "avatar_key": "picture",
+}
+
+# --- Authentik OIDC -------------------------------------------------------
+
+PROVIDERS["authentik"] = {
+ "registry": {
+ "client_id": "authentik-phase-console",
+ "client_secret": "authentik-secret-xxxxxxxxxxxxxxxx",
+ "issuer": "https://auth.example.com",
+ "scopes": "openid email profile",
+ "adapter_module": "api.authentication.providers.authentik.views",
+ "adapter_class": "AuthentikOpenIDConnectAdapter",
+ "provider_id": "authentik",
+ "token_auth_method": "client_secret_post",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "ak-access-token-xxxxxxxxxxxx",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.authentik-id-token",
+ "scope": "openid email profile",
+ },
+ # Authentik userinfo — includes Authentik-specific fields
+ "extra_data": {
+ "sub": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "email": "alice@authentik-org.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "given_name": "Alice",
+ "family_name": "Johnson",
+ "preferred_username": "alice",
+ "nickname": "alice",
+ "groups": ["phase-users", "admins"],
+ },
+ "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "expected_email": "alice@authentik-org.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": None,
+ "avatar_key": "picture",
+}
+
+# --- Authelia OIDC --------------------------------------------------------
+
+PROVIDERS["authelia"] = {
+ "registry": {
+ "client_id": "phase-console",
+ "client_secret": "authelia-secret-xxxxxxxxxxxxxxxx",
+ "issuer": "https://auth.example.com",
+ "scopes": "openid email profile",
+ "adapter_module": "api.authentication.providers.authelia.views",
+ "adapter_class": "AutheliaOpenIDConnectAdapter",
+ "provider_id": "authelia",
+ "token_auth_method": "client_secret_post",
+ "is_oidc": True,
+ },
+ "token_response": {
+ "access_token": "authelia-access-token-xxxxxxxxxxxx",
+ "token_type": "Bearer",
+ "expires_in": 3600,
+ "id_token": "eyJhbGciOiJSUzI1NiJ9.authelia-id-token",
+ "scope": "openid email profile",
+ },
+ # Authelia userinfo — uses standard OIDC claims + AMR
+ "extra_data": {
+ "sub": "79e01414-21b4-49b1-9b61-f9774b980350",
+ "email": "alice@authelia-org.com",
+ "email_verified": True,
+ "name": "Alice Johnson",
+ "preferred_username": "alice",
+ "amr": ["pwd", "totp"],
+ "rat": 1774504670,
+ "updated_at": 1774504675,
+ },
+ "uid": "79e01414-21b4-49b1-9b61-f9774b980350",
+ "expected_email": "alice@authelia-org.com",
+ "expected_name": "Alice Johnson",
+ "expected_avatar": None,
+ "avatar_key": "picture",
+}
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 1. SSOCallbackView happy-path — per-provider parametrised tests
+# ═══════════════════════════════════════════════════════════════════════════
+
+class SSOCallbackHappyPathTest(unittest.TestCase):
+ """
+ For each provider: simulate a valid OAuth callback and verify the full
+ flow completes — token exchange, adapter call, user login, redirect.
+ """
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def _run_happy_path(self, slug, fixture):
+ """Core helper that executes one provider's happy path."""
+ config = fixture["registry"]
+ state = "valid-random-state-42"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=auth-code-xxx&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = f"https://console.phase.dev/api/auth/callback/{slug}"
+ request.session["sso_token_url"] = config.get("token_url") or "https://idp.example.com/token"
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid=fixture["uid"],
+ provider_id=config["provider_id"],
+ email=fixture["expected_email"],
+ )
+
+ mock_social_app = MagicMock()
+ mock_social_app.client_id = config["client_id"]
+
+ mock_user = MagicMock()
+ mock_user.is_authenticated = True
+ mock_user.userId = "test-uuid-001"
+ mock_user.email = fixture["expected_email"]
+
+ # Mock SocialToken class — its constructor validates ForeignKey(app)
+ # against a real SocialApp instance, which breaks with MagicMock.
+ mock_social_token_cls = MagicMock()
+ mock_social_token_instance = MagicMock()
+ mock_social_token_cls.return_value = mock_social_token_instance
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=False), \
+ patch("api.views.sso._exchange_code_for_token", return_value=fixture["token_response"]) as mock_exchange, \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter) as mock_get_adapter, \
+ patch("api.views.sso._get_or_create_social_app", return_value=mock_social_app), \
+ patch("api.views.sso.SocialToken", mock_social_token_cls), \
+ patch("api.views.sso._complete_login_bypassing_allauth", return_value=mock_user) as mock_complete, \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ request.user = mock_user
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ # --- Assertions ---
+
+ # 1. Token exchange called with correct token URL and auth method
+ mock_exchange.assert_called_once()
+ call_args = mock_exchange.call_args
+ self.assertEqual(call_args[0][2], config.get("token_auth_method", "client_secret_post"))
+
+ # 2. Adapter instantiated and complete_login invoked
+ mock_get_adapter.assert_called_once()
+ mock_adapter.complete_login.assert_called_once()
+ # The token_data (response kwarg) must include the access_token
+ cl_kwargs = mock_adapter.complete_login.call_args
+ response_arg = cl_kwargs[1].get("response") or cl_kwargs[0][3]
+ self.assertIn("access_token", fixture["token_response"])
+
+ # 3. _complete_login_bypassing_allauth called
+ mock_complete.assert_called_once()
+
+ # 4. Redirect to frontend root
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(
+ response.url.startswith("https://console.phase.dev"),
+ f"Expected redirect to frontend, got: {response.url}",
+ )
+
+ # 5. SSO session keys cleaned up
+ for key in ["sso_state", "sso_provider", "sso_callback_url", "sso_token_url", "sso_nonce", "sso_return_to"]:
+ self.assertNotIn(key, request.session, f"Session key '{key}' should be cleaned up")
+
+ return response
+
+ # --- One test method per provider ---
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_google_oauth2(self):
+ self._run_happy_path("google", PROVIDERS["google"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_github_oauth2(self):
+ self._run_happy_path("github", PROVIDERS["github"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_gitlab_oauth2(self):
+ self._run_happy_path("gitlab", PROVIDERS["gitlab"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_github_enterprise(self):
+ self._run_happy_path("github-enterprise", PROVIDERS["github-enterprise"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_entra_id_oidc(self):
+ self._run_happy_path("entra-id-oidc", PROVIDERS["entra-id-oidc"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_google_oidc(self):
+ self._run_happy_path("google-oidc", PROVIDERS["google-oidc"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_jumpcloud_oidc(self):
+ self._run_happy_path("jumpcloud-oidc", PROVIDERS["jumpcloud-oidc"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_okta_oidc(self):
+ self._run_happy_path("okta-oidc", PROVIDERS["okta-oidc"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_authentik(self):
+ self._run_happy_path("authentik", PROVIDERS["authentik"])
+
+ @patch.dict(SSO_PROVIDER_REGISTRY, {}, clear=True)
+ def test_authelia(self):
+ self._run_happy_path("authelia", PROVIDERS["authelia"])
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 2. Deep-link redirect preservation
+# ═══════════════════════════════════════════════════════════════════════════
+
+class SSOCallbackDeepLinkTest(unittest.TestCase):
+ """Verify that sso_return_to deep link is preserved through login."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_deep_link_redirect(self):
+ """After SSO login, user is redirected to the original deep link."""
+ slug = "google"
+ config = PROVIDERS["google"]["registry"]
+ state = "deep-link-state-99"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code-xxx&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = "https://console.phase.dev/api/auth/callback/google"
+ request.session["sso_token_url"] = config["token_url"]
+ request.session["sso_return_to"] = "/myteam/apps/myapp/secrets"
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=PROVIDERS["google"]["extra_data"],
+ uid=PROVIDERS["google"]["uid"],
+ provider_id="google",
+ email="alice@gmail.com",
+ )
+
+ mock_user = MagicMock()
+ mock_user.is_authenticated = True
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=PROVIDERS["google"]["token_response"]), \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter), \
+ patch("api.views.sso._get_or_create_social_app", return_value=MagicMock()), \
+ patch("api.views.sso.SocialToken", MagicMock()), \
+ patch("api.views.sso._complete_login_bypassing_allauth", return_value=mock_user), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ request.user = mock_user
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "https://console.phase.dev/myteam/apps/myapp/secrets")
+
+ def test_non_relative_return_to_is_ignored(self):
+ """return_to values that don't start with / are ignored (prevents open redirect)."""
+ slug = "google"
+ config = PROVIDERS["google"]["registry"]
+ state = "open-redirect-state"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code-xxx&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = "https://console.phase.dev/api/auth/callback/google"
+ request.session["sso_token_url"] = config["token_url"]
+ request.session["sso_return_to"] = "https://evil.com/steal"
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=PROVIDERS["google"]["extra_data"],
+ uid=PROVIDERS["google"]["uid"],
+ provider_id="google",
+ )
+
+ mock_user = MagicMock()
+ mock_user.is_authenticated = True
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=PROVIDERS["google"]["token_response"]), \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter), \
+ patch("api.views.sso._get_or_create_social_app", return_value=MagicMock()), \
+ patch("api.views.sso.SocialToken", MagicMock()), \
+ patch("api.views.sso._complete_login_bypassing_allauth", return_value=mock_user), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ request.user = mock_user
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ # Should redirect to root, not the evil URL
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "https://console.phase.dev/")
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 3. Domain whitelist enforcement on happy path
+# ═══════════════════════════════════════════════════════════════════════════
+
+class SSOCallbackDomainWhitelistTest(unittest.TestCase):
+ """Verify domain whitelist blocks disallowed emails during callback."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", ["acme.com"])
+ def test_whitelisted_domain_passes(self):
+ """User with email matching whitelist can complete login."""
+ slug = "google"
+ config = PROVIDERS["google"]["registry"]
+ state = "wl-ok-state"
+
+ extra = {**PROVIDERS["google"]["extra_data"], "email": "alice@acme.com"}
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = "https://console.phase.dev/api/auth/callback/google"
+ request.session["sso_token_url"] = config["token_url"]
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=extra, uid="123", provider_id="google", email="alice@acme.com"
+ )
+
+ mock_user = MagicMock()
+ mock_user.is_authenticated = True
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=PROVIDERS["google"]["token_response"]), \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter), \
+ patch("api.views.sso._get_or_create_social_app", return_value=MagicMock()), \
+ patch("api.views.sso.SocialToken", MagicMock()), \
+ patch("api.views.sso._complete_login_bypassing_allauth", return_value=mock_user), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ request.user = mock_user
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertNotIn("error", response.url)
+
+ @patch("api.views.sso.DOMAIN_WHITELIST", ["acme.com"])
+ def test_non_whitelisted_domain_blocked(self):
+ """User with email outside whitelist is rejected."""
+ slug = "google"
+ config = PROVIDERS["google"]["registry"]
+ state = "wl-blocked-state"
+
+ extra = {**PROVIDERS["google"]["extra_data"], "email": "alice@evil.com"}
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = "https://console.phase.dev/api/auth/callback/google"
+ request.session["sso_token_url"] = config["token_url"]
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=extra, uid="123", provider_id="google", email="alice@evil.com"
+ )
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=PROVIDERS["google"]["token_response"]), \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter), \
+ patch("api.views.sso._get_or_create_social_app", return_value=MagicMock()), \
+ patch("api.views.sso.SocialToken", MagicMock()), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("email_domain_not_allowed", response.url)
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 4. _complete_login_bypassing_allauth — user creation / linking
+# ═══════════════════════════════════════════════════════════════════════════
+
+class CompleteLoginBypassingAllauthTest(unittest.TestCase):
+ """Test user creation, linking, and login for each provider's data shape."""
+
+ def _make_request(self):
+ factory = RequestFactory()
+ request = factory.get("/")
+ _add_session(request)
+ return request
+
+ def _run_new_user(self, provider_slug):
+ """A brand-new user logs in via the given provider.
+
+ Identity resolution walks (provider, uid) → email → create. For a
+ first-time login both lookups miss; we fall through to
+ User.create_user + SocialAccount.create.
+ """
+ fixture = PROVIDERS[provider_slug]
+ request = self._make_request()
+
+ social_login = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid=fixture["uid"],
+ provider_id=fixture["registry"]["provider_id"],
+ email=fixture["expected_email"],
+ )
+
+ mock_token = MagicMock()
+ mock_token.token = "access-token"
+ mock_token.app = MagicMock()
+
+ mock_user_cls = MagicMock()
+ mock_user_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_user_cls.objects.get.side_effect = mock_user_cls.DoesNotExist()
+ new_user = MagicMock()
+ mock_user_cls.objects.create_user.return_value = new_user
+
+ mock_sa_cls = MagicMock()
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist()
+
+ with patch("api.views.sso.get_user_model", return_value=mock_user_cls), \
+ patch("api.views.sso.SocialAccount", mock_sa_cls), \
+ patch("api.views.sso.SocialToken.objects.update_or_create"), \
+ patch("api.views.sso.login") as mock_login:
+
+ user = _complete_login_bypassing_allauth(request, social_login, mock_token)
+
+ # First-time identity: both lookups miss, user + SocialAccount created.
+ mock_user_cls.objects.create_user.assert_called_once_with(
+ username=fixture["expected_email"].lower().strip(),
+ email=fixture["expected_email"].lower().strip(),
+ password=None,
+ )
+ mock_sa_cls.objects.create.assert_called_once()
+ mock_login.assert_called_once_with(request, new_user)
+ return user
+
+ def _run_existing_user(self, provider_slug):
+ """A returning user signs in via the given provider.
+
+ The (provider, uid) lookup hits an existing SocialAccount; we reuse
+ its user even if the IdP-side email changed since the last login.
+ The email-based User lookup must NOT be invoked for this path.
+ """
+ fixture = PROVIDERS[provider_slug]
+ request = self._make_request()
+
+ social_login = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid=fixture["uid"],
+ provider_id=fixture["registry"]["provider_id"],
+ email=fixture["expected_email"],
+ )
+
+ mock_token = MagicMock()
+ mock_token.token = "access-token"
+ mock_token.app = MagicMock()
+
+ existing_user = MagicMock()
+ mock_user_cls = MagicMock()
+
+ mock_sa = MagicMock()
+ mock_sa.user = existing_user
+ mock_sa_cls = MagicMock()
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.return_value = mock_sa
+
+ with patch("api.views.sso.get_user_model", return_value=mock_user_cls), \
+ patch("api.views.sso.SocialAccount", mock_sa_cls), \
+ patch("api.views.sso.SocialToken.objects.update_or_create"), \
+ patch("api.views.sso.login") as mock_login:
+
+ user = _complete_login_bypassing_allauth(request, social_login, mock_token)
+
+ # Identity resolved by (provider, uid) — no user-by-email query,
+ # no user creation, no SocialAccount creation.
+ mock_user_cls.objects.create_user.assert_not_called()
+ mock_user_cls.objects.get.assert_not_called()
+ mock_sa_cls.objects.get.assert_called_once_with(
+ provider=fixture["registry"]["provider_id"],
+ uid=fixture["uid"],
+ )
+ mock_sa_cls.objects.create.assert_not_called()
+ mock_login.assert_called_once_with(request, existing_user)
+ return user
+
+ # --- New user tests per provider ---
+
+ def test_google_new_user(self):
+ self._run_new_user("google")
+
+ def test_github_new_user(self):
+ self._run_new_user("github")
+
+ def test_gitlab_new_user(self):
+ self._run_new_user("gitlab")
+
+ def test_github_enterprise_new_user(self):
+ self._run_new_user("github-enterprise")
+
+ def test_entra_id_new_user(self):
+ self._run_new_user("entra-id-oidc")
+
+ def test_google_oidc_new_user(self):
+ self._run_new_user("google-oidc")
+
+ def test_jumpcloud_new_user(self):
+ self._run_new_user("jumpcloud-oidc")
+
+ def test_okta_new_user(self):
+ self._run_new_user("okta-oidc")
+
+ def test_authentik_new_user(self):
+ self._run_new_user("authentik")
+
+ def test_authelia_new_user(self):
+ self._run_new_user("authelia")
+
+ # --- Existing user tests per provider ---
+
+ def test_google_existing_user(self):
+ self._run_existing_user("google")
+
+ def test_github_existing_user(self):
+ self._run_existing_user("github")
+
+ def test_gitlab_existing_user(self):
+ self._run_existing_user("gitlab")
+
+ def test_entra_id_existing_user(self):
+ self._run_existing_user("entra-id-oidc")
+
+ def test_okta_existing_user(self):
+ self._run_existing_user("okta-oidc")
+
+ def test_authelia_existing_user(self):
+ self._run_existing_user("authelia")
+
+ def test_refuses_silent_link_when_email_already_has_account(self):
+ """Regression: an org admin with a self-controlled IdP could
+ otherwise invite a victim's email and impersonate them."""
+ fixture = PROVIDERS["entra-id-oidc"]
+ request = self._make_request()
+
+ social_login = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid="attacker-controlled-uid",
+ provider_id=fixture["registry"]["provider_id"],
+ email=fixture["expected_email"],
+ )
+
+ mock_token = MagicMock()
+ mock_token.token = "access-token"
+ mock_token.app = MagicMock()
+
+ existing_user = MagicMock() # victim's account
+ mock_user_cls = MagicMock()
+ mock_user_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_user_cls.objects.get.return_value = existing_user
+
+ mock_sa_cls = MagicMock()
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist()
+ # Critical: existing user has NO SocialAccount for this provider.
+ mock_sa_cls.objects.filter.return_value.exists.return_value = False
+
+ with patch("api.views.sso.get_user_model", return_value=mock_user_cls), \
+ patch("api.views.sso.SocialAccount", mock_sa_cls), \
+ patch("api.views.sso.SocialToken.objects.update_or_create"), \
+ patch("api.views.sso.login") as mock_login:
+ with self.assertRaises(ValueError) as ctx:
+ _complete_login_bypassing_allauth(request, social_login, mock_token)
+
+ # Login MUST NOT have been called and no SocialAccount created.
+ mock_login.assert_not_called()
+ mock_sa_cls.objects.create.assert_not_called()
+ mock_user_cls.objects.create_user.assert_not_called()
+ self.assertIn("already exists", str(ctx.exception).lower())
+
+ def test_long_email_uses_synthetic_username(self):
+ """Emails >64 chars get a synthetic username fitting varchar(64);
+ full email still stored in the email column."""
+ long_email = "a" * 50 + "@long-tenant-name.onmicrosoft.com" # 84 chars
+ self.assertGreater(len(long_email), 64)
+
+ request = self._make_request()
+ social_login = _make_social_login(
+ extra_data={"email": long_email},
+ uid="long-email-uid",
+ provider_id="okta-oidc",
+ email=long_email,
+ )
+
+ mock_token = MagicMock()
+ mock_token.token = "access-token"
+ mock_token.app = MagicMock()
+
+ mock_user_cls = MagicMock()
+ mock_user_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_user_cls.objects.get.side_effect = mock_user_cls.DoesNotExist()
+ mock_user_cls.objects.create_user.return_value = MagicMock()
+
+ mock_sa_cls = MagicMock()
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist()
+
+ with patch("api.views.sso.get_user_model", return_value=mock_user_cls), \
+ patch("api.views.sso.SocialAccount", mock_sa_cls), \
+ patch("api.views.sso.SocialToken.objects.update_or_create"), \
+ patch("api.views.sso.login"):
+ _complete_login_bypassing_allauth(request, social_login, mock_token)
+
+ # Username must fit in 64 chars and NOT be the raw email.
+ kwargs = mock_user_cls.objects.create_user.call_args.kwargs
+ self.assertLessEqual(len(kwargs["username"]), 64)
+ self.assertNotEqual(kwargs["username"], long_email)
+ # Email is preserved verbatim (varchar 100 fits).
+ self.assertEqual(kwargs["email"], long_email)
+
+ def test_refuses_silent_link_when_provider_already_linked_with_different_uid(self):
+ """uid rotation must be deliberate, not silently re-linked."""
+ fixture = PROVIDERS["okta-oidc"]
+ request = self._make_request()
+
+ social_login = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid="rotated-uid",
+ provider_id=fixture["registry"]["provider_id"],
+ email=fixture["expected_email"],
+ )
+
+ mock_token = MagicMock()
+ mock_token.token = "access-token"
+ mock_token.app = MagicMock()
+
+ existing_user = MagicMock()
+ mock_user_cls = MagicMock()
+ mock_user_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_user_cls.objects.get.return_value = existing_user
+
+ mock_sa_cls = MagicMock()
+ mock_sa_cls.DoesNotExist = type("DoesNotExist", (Exception,), {})
+ mock_sa_cls.objects.get.side_effect = mock_sa_cls.DoesNotExist()
+ # Provider already linked with a DIFFERENT uid.
+ mock_sa_cls.objects.filter.return_value.exists.return_value = True
+
+ with patch("api.views.sso.get_user_model", return_value=mock_user_cls), \
+ patch("api.views.sso.SocialAccount", mock_sa_cls), \
+ patch("api.views.sso.SocialToken.objects.update_or_create"), \
+ patch("api.views.sso.login") as mock_login:
+ with self.assertRaises(ValueError):
+ _complete_login_bypassing_allauth(request, social_login, mock_token)
+
+ mock_login.assert_not_called()
+ mock_sa_cls.objects.create.assert_not_called()
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 4b. Org-level SocialApp name length (varchar 40 in allauth schema)
+# ═══════════════════════════════════════════════════════════════════════════
+
+
+class GetOrCreateSocialAppNameLengthTest(unittest.TestCase):
+ """Regression: `socialaccount_socialapp.name` is varchar(40) in
+ allauth's schema. Constructed names like
+ "jumpcloud-oidc:" (51 chars) overflowed the column and
+ StringDataRightTruncation'd every first org-OIDC callback.
+ SQLite (used in tests) doesn't enforce VARCHAR length, so an
+ explicit assertion on the constructed name catches it here."""
+
+ def _run(self, provider_id):
+ from api.views.sso import _get_or_create_social_app
+
+ config = {
+ "provider_id": provider_id,
+ "client_id": "client-abc",
+ "client_secret": "secret-xyz",
+ }
+ org_config_id = "12ba2063-d295-4440-bb88-7ef0ffdf8c5d" # UUID, 36 chars
+
+ with patch("api.views.sso.SocialApp") as mock_app_cls:
+ mock_app_cls.objects.filter.return_value.first.return_value = None
+ _get_or_create_social_app(config, org_config_id=org_config_id)
+
+ kwargs = mock_app_cls.objects.create.call_args.kwargs
+ self.assertLessEqual(
+ len(kwargs["name"]),
+ 40,
+ f"name {kwargs['name']!r} ({len(kwargs['name'])} chars) "
+ f"exceeds allauth's varchar(40)",
+ )
+
+ def test_okta_name_fits(self):
+ self._run("okta-oidc")
+
+ def test_entra_name_fits(self):
+ self._run("entra-id-oidc")
+
+ def test_jumpcloud_name_fits(self):
+ self._run("jumpcloud-oidc")
+
+ def test_github_enterprise_name_fits(self):
+ self._run("github-enterprise")
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 5. Email extraction edge cases
+# ═══════════════════════════════════════════════════════════════════════════
+
+class EmailExtractionTest(unittest.TestCase):
+ """
+ _complete_login_bypassing_allauth extracts email from extra_data in
+ priority order: extra_data['email'] → extra_data['mail'] → user.email
+ Test that each provider's data shape resolves correctly.
+ """
+
+ def _extract_email(self, extra_data, user_email=""):
+ """Replicate the email extraction logic from _complete_login_bypassing_allauth."""
+ email = (
+ extra_data.get("email")
+ or extra_data.get("mail")
+ or user_email
+ or None
+ )
+ return email.lower().strip() if email else None
+
+ def test_google_email_from_standard_field(self):
+ email = self._extract_email(PROVIDERS["google"]["extra_data"])
+ self.assertEqual(email, "alice@gmail.com")
+
+ def test_github_email_from_standard_field(self):
+ email = self._extract_email(PROVIDERS["github"]["extra_data"])
+ self.assertEqual(email, "alice@github.com")
+
+ def test_gitlab_email_from_standard_field(self):
+ email = self._extract_email(PROVIDERS["gitlab"]["extra_data"])
+ self.assertEqual(email, "alice@gitlab.com")
+
+ def test_entra_id_email_from_mail_field(self):
+ """Entra ID uses 'mail' not 'email' — verify fallback works."""
+ extra = PROVIDERS["entra-id-oidc"]["extra_data"]
+ # The extra_data has no 'email' key — it uses 'mail'
+ email = self._extract_email(extra)
+ self.assertEqual(email, "alice@contoso.com")
+
+ def test_entra_id_null_mail_falls_back_to_user_email(self):
+ """When Entra ID 'mail' is null, fall back to social_login.user.email."""
+ extra = {**PROVIDERS["entra-id-oidc"]["extra_data"], "mail": None}
+ # Neither 'email' nor 'mail' available — uses user_email
+ email = self._extract_email(extra, user_email="alice@contoso.com")
+ self.assertEqual(email, "alice@contoso.com")
+
+ def test_oidc_email_from_standard_claim(self):
+ """Standard OIDC providers (Okta, JumpCloud, etc.) use 'email'."""
+ for slug in ["okta-oidc", "jumpcloud-oidc", "authentik", "authelia"]:
+ email = self._extract_email(PROVIDERS[slug]["extra_data"])
+ self.assertEqual(
+ email, PROVIDERS[slug]["expected_email"],
+ f"Email mismatch for {slug}",
+ )
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 6. auth_me avatar and name extraction per provider
+# ═══════════════════════════════════════════════════════════════════════════
+
+class AuthMeProviderDataTest(unittest.TestCase):
+ """
+ GET /auth/me/ extracts avatar and name from SocialAccount.extra_data.
+ Verify each provider's data shape produces the correct output.
+ """
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def _call_auth_me(self, extra_data, user_email="test@example.com"):
+ """Call auth_me with a mock user and social account."""
+ request = self.factory.get("/auth/me/")
+ user = MagicMock()
+ user.is_authenticated = True
+ user.userId = "user-uuid"
+ user.email = user_email
+ user.full_name = ""
+ user.auth_method = "sso"
+
+ social_acc = MagicMock()
+ social_acc.extra_data = extra_data
+ user.socialaccount_set.first.return_value = social_acc
+ request.user = user
+ _add_session(request)
+
+ response = auth_me(request)
+ return json.loads(response.content)
+
+ def test_google_avatar_and_name(self):
+ data = self._call_auth_me(PROVIDERS["google"]["extra_data"])
+ self.assertEqual(data["avatarUrl"], PROVIDERS["google"]["expected_avatar"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_github_avatar_and_name(self):
+ data = self._call_auth_me(PROVIDERS["github"]["extra_data"])
+ self.assertEqual(data["avatarUrl"], PROVIDERS["github"]["expected_avatar"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_gitlab_avatar_and_name(self):
+ """GitLab uses 'avatar_url' which is caught by the GitHub branch."""
+ data = self._call_auth_me(PROVIDERS["gitlab"]["extra_data"])
+ self.assertEqual(data["avatarUrl"], PROVIDERS["gitlab"]["expected_avatar"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_github_enterprise_avatar_and_name(self):
+ data = self._call_auth_me(PROVIDERS["github-enterprise"]["extra_data"])
+ self.assertEqual(data["avatarUrl"], PROVIDERS["github-enterprise"]["expected_avatar"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_entra_id_avatar_and_name(self):
+ """Entra ID: no photo URL in extra_data, name from 'name' field."""
+ data = self._call_auth_me(PROVIDERS["entra-id-oidc"]["extra_data"])
+ # Entra ID doesn't return a photo URL in Graph /me
+ self.assertIsNone(data["avatarUrl"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_google_oidc_avatar_and_name(self):
+ data = self._call_auth_me(PROVIDERS["google-oidc"]["extra_data"])
+ self.assertEqual(data["avatarUrl"], PROVIDERS["google-oidc"]["expected_avatar"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_jumpcloud_no_avatar(self):
+ """JumpCloud typically doesn't return a picture URL."""
+ data = self._call_auth_me(PROVIDERS["jumpcloud-oidc"]["extra_data"])
+ self.assertIsNone(data["avatarUrl"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_okta_no_avatar(self):
+ """Okta doesn't return picture by default scope."""
+ data = self._call_auth_me(PROVIDERS["okta-oidc"]["extra_data"])
+ self.assertIsNone(data["avatarUrl"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_authentik_no_avatar(self):
+ data = self._call_auth_me(PROVIDERS["authentik"]["extra_data"])
+ self.assertIsNone(data["avatarUrl"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_authelia_no_avatar(self):
+ data = self._call_auth_me(PROVIDERS["authelia"]["extra_data"])
+ self.assertIsNone(data["avatarUrl"])
+ self.assertEqual(data["fullName"], "Alice Johnson")
+
+ def test_no_social_account_falls_back_to_email(self):
+ """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
+ user.userId = "user-uuid"
+ user.email = "alice@test.com"
+ user.full_name = ""
+ user.auth_method = "sso"
+ 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.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
+# ═══════════════════════════════════════════════════════════════════════════
+
+class TokenExchangeAuthMethodTest(unittest.TestCase):
+ """Verify token exchange uses the correct auth method per provider."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def _run_callback_and_capture_exchange(self, slug, fixture):
+ """Run the callback and capture the _exchange_code_for_token call."""
+ config = fixture["registry"]
+ state = "auth-method-state"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = f"https://console.phase.dev/api/auth/callback/{slug}"
+ request.session["sso_token_url"] = config.get("token_url") or "https://idp.example.com/token"
+ request.session.save()
+
+ mock_adapter = MagicMock()
+ mock_adapter.complete_login.return_value = _make_social_login(
+ extra_data=fixture["extra_data"],
+ uid=fixture["uid"],
+ provider_id=config["provider_id"],
+ email=fixture["expected_email"],
+ )
+ mock_user = MagicMock()
+ mock_user.is_authenticated = True
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=fixture["token_response"]) as mock_exchange, \
+ patch("api.views.sso._get_adapter_instance", return_value=mock_adapter), \
+ patch("api.views.sso._get_or_create_social_app", return_value=MagicMock()), \
+ patch("api.views.sso.SocialToken", MagicMock()), \
+ patch("api.views.sso._complete_login_bypassing_allauth", return_value=mock_user), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ request.user = mock_user
+ view = SSOCallbackView()
+ view.get(request, slug)
+
+ return mock_exchange.call_args
+
+ def test_okta_uses_client_secret_basic(self):
+ """Okta requires HTTP Basic auth on the token endpoint."""
+ call = self._run_callback_and_capture_exchange("okta-oidc", PROVIDERS["okta-oidc"])
+ # Third positional arg is auth_method
+ self.assertEqual(call[0][2], "client_secret_basic")
+
+ def test_google_uses_client_secret_post(self):
+ call = self._run_callback_and_capture_exchange("google", PROVIDERS["google"])
+ self.assertEqual(call[0][2], "client_secret_post")
+
+ def test_github_uses_client_secret_post(self):
+ call = self._run_callback_and_capture_exchange("github", PROVIDERS["github"])
+ self.assertEqual(call[0][2], "client_secret_post")
+
+ def test_authelia_uses_client_secret_post(self):
+ call = self._run_callback_and_capture_exchange("authelia", PROVIDERS["authelia"])
+ self.assertEqual(call[0][2], "client_secret_post")
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 8. Token exchange failure handling
+# ═══════════════════════════════════════════════════════════════════════════
+
+class SSOCallbackTokenExchangeFailureTest(unittest.TestCase):
+ """Verify graceful handling when token exchange fails per provider."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def _run_exchange_failure(self, slug, fixture):
+ config = fixture["registry"]
+ state = "fail-state"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = f"https://console.phase.dev/api/auth/callback/{slug}"
+ request.session["sso_token_url"] = config.get("token_url") or "https://idp.example.com/token"
+ request.session.save()
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", side_effect=Exception("Connection refused")), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("token_exchange_failed", response.url)
+
+ def test_google_token_exchange_failure(self):
+ self._run_exchange_failure("google", PROVIDERS["google"])
+
+ def test_github_token_exchange_failure(self):
+ self._run_exchange_failure("github", PROVIDERS["github"])
+
+ def test_okta_token_exchange_failure(self):
+ self._run_exchange_failure("okta-oidc", PROVIDERS["okta-oidc"])
+
+ def test_entra_id_token_exchange_failure(self):
+ self._run_exchange_failure("entra-id-oidc", PROVIDERS["entra-id-oidc"])
+
+
+# ═══════════════════════════════════════════════════════════════════════════
+# 9. No access_token in token response
+# ═══════════════════════════════════════════════════════════════════════════
+
+class SSOCallbackNoAccessTokenTest(unittest.TestCase):
+ """Some IdPs may return a response without access_token on error."""
+
+ def setUp(self):
+ self.factory = RequestFactory()
+
+ def test_missing_access_token_redirects_with_error(self):
+ slug = "github"
+ config = PROVIDERS["github"]["registry"]
+ state = "no-token-state"
+
+ request = self.factory.get(
+ f"/auth/sso/{slug}/callback/?code=code&state={state}"
+ )
+ _add_session(request)
+ request.session["sso_state"] = state
+ request.session["sso_provider"] = slug
+ request.session["sso_callback_url"] = "https://console.phase.dev/api/auth/callback/github"
+ request.session["sso_token_url"] = config["token_url"]
+ request.session.save()
+
+ # GitHub sometimes returns {"error": "bad_verification_code"} with no access_token
+ bad_token_response = {
+ "error": "bad_verification_code",
+ "error_description": "The code passed is incorrect or expired.",
+ "error_uri": "https://docs.github.com/apps/managing-oauth-apps/troubleshooting-oauth-app-access-token-request-errors/#bad-verification-code",
+ }
+
+ with patch.dict(SSO_PROVIDER_REGISTRY, {slug: config}, clear=True), \
+ patch("api.views.sso._exchange_code_for_token", return_value=bad_token_response), \
+ patch("api.views.sso.FRONTEND_URL", "https://console.phase.dev"):
+
+ view = SSOCallbackView()
+ response = view.get(request, slug)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertIn("no_access_token", response.url)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/apollo/client.ts b/frontend/apollo/client.ts
index 154a3b321..2c3f915e7 100644
--- a/frontend/apollo/client.ts
+++ b/frontend/apollo/client.ts
@@ -1,20 +1,33 @@
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 { deleteDeviceKey, clearActivePasswordUser, getActivePasswordUser } from '@/utils/localStorage'
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)
+ // Drop the deviceKey for the active password user only. SSO users use
+ // `phaseMemberDeviceKeys` and are unaffected. The userId is stashed by
+ // UserProvider so this works for both manual logout and the auto-logout
+ // path below when a session cookie expires.
+ const activeUserId = getActivePasswordUser()
+ if (activeUserId) {
+ deleteDeviceKey(activeUserId)
+ clearActivePasswordUser()
+ }
+ 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({
@@ -35,6 +48,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(
@@ -45,7 +71,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/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..7d3f9d707 100644
--- a/frontend/apollo/gql.ts
+++ b/frontend/apollo/gql.ts
@@ -11,8 +11,179 @@ 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 ChangePassword($orgId: ID!, $currentAuthHash: String!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n changeAccountPassword(\n orgId: $orgId\n currentAuthHash: $currentAuthHash\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.ChangePasswordDocument,
+ "mutation RecoverKeyring($orgId: ID!, $authHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n recoverAccountKeyring(\n orgId: $orgId\n authHash: $authHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": typeof types.RecoverKeyringDocument,
+ "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!, $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,
+ "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 VerifyPassword($authHash: String!) {\n verifyPassword(authHash: $authHash)\n}": typeof types.VerifyPasswordDocument,
+ "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,
@@ -25,6 +196,8 @@ const 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 ChangePassword($orgId: ID!, $currentAuthHash: String!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n changeAccountPassword(\n orgId: $orgId\n currentAuthHash: $currentAuthHash\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.ChangePasswordDocument,
+ "mutation RecoverKeyring($orgId: ID!, $authHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n recoverAccountKeyring(\n orgId: $orgId\n authHash: $authHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}": types.RecoverKeyringDocument,
"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,
@@ -71,7 +244,7 @@ const 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,
@@ -81,6 +254,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,
@@ -108,6 +286,7 @@ const documents = {
"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}": 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}": 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}": types.GetAppServiceAccountsDocument,
+ "query VerifyPassword($authHash: String!) {\n verifyPassword(authHash: $authHash)\n}": types.VerifyPasswordDocument,
"query GetCheckoutDetails($stripeSessionId: String!) {\n stripeCheckoutDetails(stripeSessionId: $stripeSessionId) {\n paymentStatus\n customerEmail\n billingStartDate\n billingEndDate\n subscriptionId\n planName\n }\n}": types.GetCheckoutDetailsDocument,
"query GetCustomerPortalLink($organisationId: ID!) {\n stripeCustomerPortalUrl(organisationId: $organisationId)\n}": 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}": types.GetSubscriptionDetailsDocument,
@@ -117,7 +296,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 +328,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,
@@ -236,6 +416,14 @@ 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 ChangePassword($orgId: ID!, $currentAuthHash: String!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n changeAccountPassword(\n orgId: $orgId\n currentAuthHash: $currentAuthHash\n newAuthHash: $newAuthHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation ChangePassword($orgId: ID!, $currentAuthHash: String!, $newAuthHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n changeAccountPassword(\n orgId: $orgId\n currentAuthHash: $currentAuthHash\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.
+ */
+export function graphql(source: "mutation RecoverKeyring($orgId: ID!, $authHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n recoverAccountKeyring(\n orgId: $orgId\n authHash: $authHash\n identityKey: $identityKey\n wrappedKeyring: $wrappedKeyring\n wrappedRecovery: $wrappedRecovery\n ) {\n orgMember {\n id\n }\n }\n}"): (typeof documents)["mutation RecoverKeyring($orgId: ID!, $authHash: String!, $identityKey: String!, $wrappedKeyring: String!, $wrappedRecovery: String!) {\n recoverAccountKeyring(\n orgId: $orgId\n authHash: $authHash\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.
*/
@@ -423,7 +611,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.
*/
@@ -460,6 +648,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.
*/
@@ -568,6 +776,10 @@ export function graphql(source: "query GetAppMembers($appId: ID!) {\n appUsers(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "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 documents)["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}"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "query VerifyPassword($authHash: String!) {\n verifyPassword(authHash: $authHash)\n}"): (typeof documents)["query VerifyPassword($authHash: String!) {\n verifyPassword(authHash: $authHash)\n}"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -607,7 +819,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 +944,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..9d6f58b1c 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 */
@@ -331,6 +339,32 @@ export type BulkInviteOrganisationMembersMutation = {
invites?: Maybe>>;
};
+/**
+ * Rotate the user's account password and rewrap the active org's
+ * keyring with the new deviceKey. Used by the in-session change-password
+ * dialog where the user supplies their current password, a new password,
+ * and the org's recovery mnemonic.
+ *
+ * Three server-side proofs are required:
+ * 1. current_auth_hash matches user.password — proves the caller
+ * knows the current login password.
+ * 2. identity_key matches the org's stored identity_key — proves the
+ * caller derived the keyring from the right mnemonic.
+ * 3. user is a member of the org.
+ *
+ * On success: user.password is set to new_auth_hash, the org's
+ * wrapped_keyring + wrapped_recovery are replaced, and the session is
+ * refreshed so the post-rotation HASH_SESSION_KEY stays valid.
+ *
+ * Only the active org's keyring is rewrapped. Other orgs the user
+ * belongs to remain encrypted with the old deviceKey; they'll fall
+ * through to per-org recovery on next access.
+ */
+export type ChangeAccountPasswordMutation = {
+ __typename?: 'ChangeAccountPasswordMutation';
+ orgMember?: Maybe;
+};
+
export type ChartDataPointType = {
__typename?: 'ChartDataPointType';
data?: Maybe;
@@ -446,6 +480,11 @@ export type CreateOrganisationMutation = {
organisation?: Maybe;
};
+export type CreateOrganisationSsoProviderMutation = {
+ __typename?: 'CreateOrganisationSSOProviderMutation';
+ providerId?: Maybe;
+};
+
export type CreatePersonalSecretMutation = {
__typename?: 'CreatePersonalSecretMutation';
override?: Maybe;
@@ -562,6 +601,11 @@ export type DeleteOrganisationMemberMutation = {
ok?: Maybe;
};
+export type DeleteOrganisationSsoProviderMutation = {
+ __typename?: 'DeleteOrganisationSSOProviderMutation';
+ ok?: Maybe;
+};
+
export type DeletePaymentMethodMutation = {
__typename?: 'DeletePaymentMethodMutation';
ok?: Maybe;
@@ -961,6 +1005,28 @@ export type Mutation = {
bulkAddAppMembers?: Maybe;
bulkInviteOrganisationMembers?: Maybe;
cancelSubscription?: Maybe;
+ /**
+ * Rotate the user's account password and rewrap the active org's
+ * keyring with the new deviceKey. Used by the in-session change-password
+ * dialog where the user supplies their current password, a new password,
+ * and the org's recovery mnemonic.
+ *
+ * Three server-side proofs are required:
+ * 1. current_auth_hash matches user.password — proves the caller
+ * knows the current login password.
+ * 2. identity_key matches the org's stored identity_key — proves the
+ * caller derived the keyring from the right mnemonic.
+ * 3. user is a member of the org.
+ *
+ * On success: user.password is set to new_auth_hash, the org's
+ * wrapped_keyring + wrapped_recovery are replaced, and the session is
+ * refreshed so the post-rotation HASH_SESSION_KEY stays valid.
+ *
+ * Only the active org's keyring is rewrapped. Other orgs the user
+ * belongs to remain encrypted with the old deviceKey; they'll fall
+ * through to per-org recovery on next access.
+ */
+ changeAccountPassword?: Maybe;
createApp?: Maybe;
createAwsDynamicSecret?: Maybe;
createAwsSecretSync?: Maybe;
@@ -981,6 +1047,7 @@ export type Mutation = {
createNomadSync?: Maybe;
createOrganisation?: Maybe;
createOrganisationMember?: Maybe;
+ createOrganisationSsoProvider?: Maybe;
createOverride?: Maybe;
createProviderCredentials?: Maybe;
createRailwaySync?: Maybe;
@@ -1006,6 +1073,7 @@ export type Mutation = {
deleteInvitation?: Maybe;
deleteNetworkAccessPolicy?: Maybe;
deleteOrganisationMember?: Maybe;
+ deleteOrganisationSsoProvider?: Maybe;
deletePaymentMethod?: Maybe;
deleteProviderCredentials?: Maybe;
deleteSecret?: Maybe;
@@ -1023,6 +1091,24 @@ export type Mutation = {
migratePricing?: Maybe;
modifySubscription?: Maybe;
readSecret?: Maybe;
+ /**
+ * Rewrap THIS org's keyring with a deviceKey derived from the user's
+ * account password. Used by the recovery flow when the local keyring
+ * has been lost (cleared cache, new device) but the user still
+ * remembers their password.
+ *
+ * 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. auth_hash matches user.password — proves the password the user
+ * is wrapping the keyring with is also their account login auth.
+ *
+ * The mutation does NOT change user.password. The auth_hash check is a
+ * guardrail to keep auth and wrap passwords unified; if it fails, the
+ * user is trying to wrap the keyring with a password that doesn't
+ * authenticate them, which we never persist.
+ */
+ recoverAccountKeyring?: Maybe;
removeAppMember?: Maybe;
removeOverride?: Maybe;
renameEnvironment?: Maybe;
@@ -1031,6 +1117,7 @@ export type Mutation = {
revokeDynamicSecretLease?: Maybe;
rotateAppKeys?: Maybe;
setDefaultPaymentMethod?: Maybe;
+ testOrganisationSsoProvider?: Maybe;
toggleSyncActive?: Maybe;
/**
* Transfer organisation ownership from the current owner to another member.
@@ -1045,9 +1132,23 @@ 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;
+ updateOrganisationSecurity?: Maybe;
+ updateOrganisationSsoProvider?: Maybe;
updateProviderCredentials?: Maybe;
updateServiceAccount?: Maybe;
updateServiceAccountHandlers?: Maybe;
@@ -1081,6 +1182,16 @@ export type MutationCancelSubscriptionArgs = {
};
+export type MutationChangeAccountPasswordArgs = {
+ currentAuthHash: Scalars['String']['input'];
+ identityKey: Scalars['String']['input'];
+ newAuthHash: Scalars['String']['input'];
+ orgId: Scalars['ID']['input'];
+ wrappedKeyring: Scalars['String']['input'];
+ wrappedRecovery: Scalars['String']['input'];
+};
+
+
export type MutationCreateAppArgs = {
appSeed: Scalars['String']['input'];
appToken: Scalars['String']['input'];
@@ -1277,6 +1388,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 +1575,11 @@ export type MutationDeleteOrganisationMemberArgs = {
};
+export type MutationDeleteOrganisationSsoProviderArgs = {
+ providerId: Scalars['ID']['input'];
+};
+
+
export type MutationDeletePaymentMethodArgs = {
organisationId?: InputMaybe;
paymentMethodId?: InputMaybe;
@@ -1549,6 +1673,15 @@ export type MutationReadSecretArgs = {
};
+export type MutationRecoverAccountKeyringArgs = {
+ authHash: Scalars['String']['input'];
+ identityKey: Scalars['String']['input'];
+ orgId: Scalars['ID']['input'];
+ wrappedKeyring: Scalars['String']['input'];
+ wrappedRecovery: Scalars['String']['input'];
+};
+
+
export type MutationRemoveAppMemberArgs = {
appId?: InputMaybe;
memberId?: InputMaybe;
@@ -1597,6 +1730,11 @@ export type MutationSetDefaultPaymentMethodArgs = {
};
+export type MutationTestOrganisationSsoProviderArgs = {
+ providerId: Scalars['ID']['input'];
+};
+
+
export type MutationToggleSyncActiveArgs = {
syncId?: InputMaybe;
};
@@ -1680,6 +1818,7 @@ export type MutationUpdateMemberEnvironmentScopeArgs = {
export type MutationUpdateMemberWrappedSecretsArgs = {
+ identityKey: Scalars['String']['input'];
orgId: Scalars['ID']['input'];
wrappedKeyring: Scalars['String']['input'];
wrappedRecovery: Scalars['String']['input'];
@@ -1697,6 +1836,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 +1951,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 +1976,9 @@ export type OrganisationType = {
planDetail?: Maybe;
pricingVersion: Scalars['Int']['output'];
recovery?: Maybe;
+ requireSso: Scalars['Boolean']['output'];
role?: Maybe;
+ ssoProviders?: Maybe>>;
};
export type PaymentMethodDetails = {
@@ -1953,6 +2121,7 @@ export type Query = {
validateAwsAssumeRoleCredentials?: Maybe;
validateInvite?: Maybe;
vercelProjects?: Maybe>>;
+ verifyPassword?: Maybe;
};
@@ -2255,6 +2424,11 @@ export type QueryVercelProjectsArgs = {
credentialId?: InputMaybe;
};
+
+export type QueryVerifyPasswordArgs = {
+ authHash: Scalars['String']['input'];
+};
+
export type RailwayEnvironmentType = {
__typename?: 'RailwayEnvironmentType';
id: Scalars['ID']['output'];
@@ -2286,6 +2460,28 @@ export type ReadSecretMutation = {
ok?: Maybe;
};
+/**
+ * Rewrap THIS org's keyring with a deviceKey derived from the user's
+ * account password. Used by the recovery flow when the local keyring
+ * has been lost (cleared cache, new device) but the user still
+ * remembers their password.
+ *
+ * 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. auth_hash matches user.password — proves the password the user
+ * is wrapping the keyring with is also their account login auth.
+ *
+ * The mutation does NOT change user.password. The auth_hash check is a
+ * guardrail to keep auth and wrap passwords unified; if it fails, the
+ * user is trying to wrap the keyring with a password that doesn't
+ * authenticate them, which we never persist.
+ */
+export type RecoverAccountKeyringMutation = {
+ __typename?: 'RecoverAccountKeyringMutation';
+ orgMember?: Maybe;
+};
+
export type RemoveAppMemberMutation = {
__typename?: 'RemoveAppMemberMutation';
app?: Maybe;
@@ -2554,6 +2750,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 +2829,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'];
@@ -2662,6 +2875,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;
@@ -2810,6 +3035,29 @@ export type UpdateEnvScopeMutationVariables = Exact<{
export type UpdateEnvScopeMutation = { __typename?: 'Mutation', updateMemberEnvironmentScope?: { __typename?: 'UpdateMemberEnvScopeMutation', app?: { __typename?: 'AppType', id: string } | null } | null };
+export type ChangePasswordMutationVariables = Exact<{
+ orgId: Scalars['ID']['input'];
+ currentAuthHash: Scalars['String']['input'];
+ newAuthHash: Scalars['String']['input'];
+ identityKey: Scalars['String']['input'];
+ wrappedKeyring: Scalars['String']['input'];
+ wrappedRecovery: Scalars['String']['input'];
+}>;
+
+
+export type ChangePasswordMutation = { __typename?: 'Mutation', changeAccountPassword?: { __typename?: 'ChangeAccountPasswordMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null } | null };
+
+export type RecoverKeyringMutationVariables = Exact<{
+ orgId: Scalars['ID']['input'];
+ authHash: Scalars['String']['input'];
+ identityKey: Scalars['String']['input'];
+ wrappedKeyring: Scalars['String']['input'];
+ wrappedRecovery: Scalars['String']['input'];
+}>;
+
+
+export type RecoverKeyringMutation = { __typename?: 'Mutation', recoverAccountKeyring?: { __typename?: 'RecoverAccountKeyringMutation', orgMember?: { __typename?: 'OrganisationMemberType', id: string } | null } | null };
+
export type CancelStripeSubscriptionMutationVariables = Exact<{
organisationId: Scalars['ID']['input'];
subscriptionId: Scalars['String']['input'];
@@ -3157,7 +3405,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 +3432,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'];
@@ -3235,6 +3489,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'];
}>;
@@ -3324,6 +3579,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'];
@@ -3587,6 +3884,13 @@ export type GetAppServiceAccountsQueryVariables = Exact<{
export type GetAppServiceAccountsQuery = { __typename?: 'Query', appServiceAccounts?: Array<{ __typename?: 'ServiceAccountType', id: string, identityKey?: string | null, name: string, createdAt?: any | null, role?: { __typename?: 'RoleType', id: string, name?: string | null, description?: string | null, permissions?: any | null, color?: string | null } | null, tokens?: Array<{ __typename?: 'ServiceAccountTokenType', id: string, name: string } | null> | null } | null> | null };
+export type VerifyPasswordQueryVariables = Exact<{
+ authHash: Scalars['String']['input'];
+}>;
+
+
+export type VerifyPasswordQuery = { __typename?: 'Query', verifyPassword?: boolean | null };
+
export type GetCheckoutDetailsQueryVariables = Exact<{
stripeSessionId: Scalars['String']['input'];
}>;
@@ -3661,7 +3965,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 +3982,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 +4204,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'];
}>;
@@ -4069,6 +4381,8 @@ 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 ChangePasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ChangePassword"},"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":"currentAuthHash"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"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":"changeAccountPassword"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"currentAuthHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"currentAuthHash"}}},{"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 RecoverKeyringDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RecoverKeyring"},"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":"authHash"}},"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":"recoverAccountKeyring"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}},{"kind":"Argument","name":{"kind":"Name","value":"authHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"authHash"}}},{"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;
@@ -4115,7 +4429,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;
@@ -4125,6 +4439,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;
@@ -4152,6 +4471,7 @@ export const GetNetworkPoliciesDocument = {"kind":"Document","definitions":[{"ki
export const GetAppAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppAccounts"},"variableDefinitions":[{"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":"appUsers"},"arguments":[{"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":"identityKey"}},{"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":"createdAt"}},{"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":"color"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"appServiceAccounts"},"arguments":[{"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":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"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":"color"}}]}},{"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"}}]}}]}}]}}]} as unknown as DocumentNode;
export const GetAppMembersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppMembers"},"variableDefinitions":[{"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":"appUsers"},"arguments":[{"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":"identityKey"}},{"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":"createdAt"}},{"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":"color"}}]}}]}}]}}]} as unknown as DocumentNode;
export const GetAppServiceAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppServiceAccounts"},"variableDefinitions":[{"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":"appServiceAccounts"},"arguments":[{"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":"identityKey"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"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":"color"}}]}},{"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"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const VerifyPasswordDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"VerifyPassword"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"authHash"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyPassword"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"authHash"},"value":{"kind":"Variable","name":{"kind":"Name","value":"authHash"}}}]}]}}]} as unknown as DocumentNode;
export const GetCheckoutDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCheckoutDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"stripeSessionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripeCheckoutDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"stripeSessionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"stripeSessionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"paymentStatus"}},{"kind":"Field","name":{"kind":"Name","value":"customerEmail"}},{"kind":"Field","name":{"kind":"Name","value":"billingStartDate"}},{"kind":"Field","name":{"kind":"Name","value":"billingEndDate"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}},{"kind":"Field","name":{"kind":"Name","value":"planName"}}]}}]}}]} as unknown as DocumentNode;
export const GetCustomerPortalLinkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCustomerPortalLink"},"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":"stripeCustomerPortalUrl"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"organisationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"organisationId"}}}]}]}}]} as unknown as DocumentNode;
export const GetSubscriptionDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSubscriptionDetails"},"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":"stripeSubscriptionDetails"},"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":"subscriptionId"}},{"kind":"Field","name":{"kind":"Name","value":"planName"}},{"kind":"Field","name":{"kind":"Name","value":"planType"}},{"kind":"Field","name":{"kind":"Name","value":"billingPeriod"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"nextPaymentAmount"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodStart"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"renewalDate"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAtPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"paymentMethods"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"brand"}},{"kind":"Field","name":{"kind":"Name","value":"last4"}},{"kind":"Field","name":{"kind":"Name","value":"expMonth"}},{"kind":"Field","name":{"kind":"Name","value":"expYear"}},{"kind":"Field","name":{"kind":"Name","value":"isDefault"}}]}}]}}]}}]} as unknown as DocumentNode;
@@ -4161,7 +4481,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 +4513,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..831f3fd45 100644
--- a/frontend/apollo/schema.graphql
+++ b/frontend/apollo/schema.graphql
@@ -5,6 +5,7 @@ type Query {
networkAccessPolicies(organisationId: ID): [NetworkAccessPolicyType]
identities(organisationId: ID): [IdentityType]
organisationNameAvailable(name: String): Boolean
+ verifyPassword(authHash: String!): Boolean
license: PhaseLicenseType
organisationLicense(organisationId: ID): ActivatedPhaseLicenseType
organisationPlan(organisationId: ID): OrganisationPlanType
@@ -71,11 +72,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 +133,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 +347,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 +408,6 @@ enum ApiSecretEventTypeChoices {
CONFIG
}
-"""An enumeration."""
-enum ApiSecretEventTypeChoices {
- """Secret"""
- SECRET
-
- """Sealed"""
- SEALED
-
- """Config"""
- CONFIG
-}
-
"""An enumeration."""
enum ApiSecretEventEventTypeChoices {
"""Create"""
@@ -1037,7 +1049,62 @@ 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
+
+ """
+ Rewrap THIS org's keyring with a deviceKey derived from the user's
+ account password. Used by the recovery flow when the local keyring
+ has been lost (cleared cache, new device) but the user still
+ remembers their password.
+
+ 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. auth_hash matches user.password — proves the password the user
+ is wrapping the keyring with is also their account login auth.
+
+ The mutation does NOT change user.password. The auth_hash check is a
+ guardrail to keep auth and wrap passwords unified; if it fails, the
+ user is trying to wrap the keyring with a password that doesn't
+ authenticate them, which we never persist.
+ """
+ recoverAccountKeyring(authHash: String!, identityKey: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): RecoverAccountKeyringMutation
+
+ """
+ Rotate the user's account password and rewrap the active org's
+ keyring with the new deviceKey. Used by the in-session change-password
+ dialog where the user supplies their current password, a new password,
+ and the org's recovery mnemonic.
+
+ Three server-side proofs are required:
+ 1. current_auth_hash matches user.password — proves the caller
+ knows the current login password.
+ 2. identity_key matches the org's stored identity_key — proves the
+ caller derived the keyring from the right mnemonic.
+ 3. user is a member of the org.
+
+ On success: user.password is set to new_auth_hash, the org's
+ wrapped_keyring + wrapped_recovery are replaced, and the session is
+ refreshed so the post-rotation HASH_SESSION_KEY stays valid.
+
+ Only the active org's keyring is rewrapped. Other orgs the user
+ belongs to remain encrypted with the old deviceKey; they'll fall
+ through to per-org recovery on next access.
+ """
+ changeAccountPassword(currentAuthHash: String!, identityKey: String!, newAuthHash: String!, orgId: ID!, wrappedKeyring: String!, wrappedRecovery: String!): ChangeAccountPasswordMutation
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
@@ -1063,6 +1130,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
@@ -1163,10 +1235,68 @@ 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
}
+"""
+Rewrap THIS org's keyring with a deviceKey derived from the user's
+account password. Used by the recovery flow when the local keyring
+has been lost (cleared cache, new device) but the user still
+remembers their password.
+
+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. auth_hash matches user.password — proves the password the user
+ is wrapping the keyring with is also their account login auth.
+
+The mutation does NOT change user.password. The auth_hash check is a
+guardrail to keep auth and wrap passwords unified; if it fails, the
+user is trying to wrap the keyring with a password that doesn't
+authenticate them, which we never persist.
+"""
+type RecoverAccountKeyringMutation {
+ orgMember: OrganisationMemberType
+}
+
+"""
+Rotate the user's account password and rewrap the active org's
+keyring with the new deviceKey. Used by the in-session change-password
+dialog where the user supplies their current password, a new password,
+and the org's recovery mnemonic.
+
+Three server-side proofs are required:
+ 1. current_auth_hash matches user.password — proves the caller
+ knows the current login password.
+ 2. identity_key matches the org's stored identity_key — proves the
+ caller derived the keyring from the right mnemonic.
+ 3. user is a member of the org.
+
+On success: user.password is set to new_auth_hash, the org's
+wrapped_keyring + wrapped_recovery are replaced, and the session is
+refreshed so the post-rotation HASH_SESSION_KEY stays valid.
+
+Only the active org's keyring is rewrapped. Other orgs the user
+belongs to remain encrypted with the old deviceKey; they'll fall
+through to per-org recovery on next access.
+"""
+type ChangeAccountPasswordMutation {
+ orgMember: OrganisationMemberType
+}
+
type DeleteInviteMutation {
ok: Boolean
}
@@ -1308,6 +1438,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/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..284bc0f8a
--- /dev/null
+++ b/frontend/app/[team]/access/sso/oidc/page.tsx
@@ -0,0 +1,685 @@
+'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 } 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) {
+ 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
+ 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 && (
+
+ {
+ if (requireSso) {
+ handleToggleEnforcement()
+ } else {
+ openEnforceDialog()
+ }
+ }}
+ >
+
+
+ {requireSso ? 'Disable SSO Enforcement' : 'Enforce SSO'}
+
+
+
+ {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 && (
+
+ {
+ if (provider.enabled) {
+ setDeletingProvider(provider)
+ disableDialogRef.current?.openModal()
+ } else {
+ handleToggleEnabled(provider)
+ }
+ }}
+ >
+
+ {provider.enabled ? 'Deactivate' : 'Activate'}
+
+
+ {
+ setTestingProvider(provider)
+ testSSODialogRef.current?.openModal()
+ }}
+ title="Test SSO login flow"
+ >
+
+ Test SSO
+
+ handleEdit(provider)}>
+
+ Edit
+
+ {
+ setDeletingProvider(provider)
+ deleteDialogRef.current?.openModal()
+ }}
+ >
+
+ Delete
+
+
+ )}
+
+
+ {/* 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 (
+
handleSetup(type)}
+ disabled={!userCanManageSSO}
+ className="flex items-center gap-3 p-4 rounded-xl border border-neutral-500/20 hover:border-emerald-500/40 hover:bg-zinc-800/50 transition ease text-left disabled:opacity-50 disabled:pointer-events-none"
+ >
+
+
+
{info.name}
+
{info.description}
+
+
+ )
+ })}
+
+
+ ) : (
+
+ {availableProviders.map((type) => {
+ const info = PROVIDER_INFO[type]
+ const Icon = info.icon
+ return (
+
handleSetup(type)}
+ disabled={!userCanManageSSO}
+ className="flex items-center gap-3 p-4 rounded-xl border border-neutral-500/20 hover:border-emerald-500/40 hover:bg-zinc-800/50 transition ease text-left disabled:opacity-50 disabled:pointer-events-none"
+ >
+
+
+
{info.name}
+
{info.description}
+
+
+ )
+ })}
+
+ )}
+
+ )}
+
+ {/* 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.
+
+ )}
+
+
+ deleteDialogRef.current?.closeModal()}>
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ {/* 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.
+
+
+
+
+
+ setEnforceAck(e.target.checked)}
+ className="size-4 mt-0.5 shrink-0"
+ />
+
+ I understand that enforcing SSO will end my current session. If I cannot sign back
+ in via SSO, I will be locked out of this organisation.
+
+
+
+
+
+ enforceDialogRef.current?.closeModal()}>
+ Cancel
+
+ {
+ enforceDialogRef.current?.closeModal()
+ handleToggleEnforcement()
+ }}
+ >
+ Enforce SSO
+
+
+
+
+
+ {/* 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.
+
+
+
+
+ testSSODialogRef.current?.closeModal()}>
+ Back
+
+ {
+ testSSODialogRef.current?.closeModal()
+ if (testingProvider) handleTestSSO(testingProvider)
+ }}
+ >
+ Continue
+
+
+ >
+ ) : (
+ <>
+
+ You need to activate this provider before you can test it.
+
+
+ testSSODialogRef.current?.closeModal()}>
+ Close
+
+
+ >
+ )}
+
+
+
+ {/* 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.
+
+ )}
+
+
+
+ disableDialogRef.current?.closeModal()}>
+ Cancel
+
+ {
+ disableDialogRef.current?.closeModal()
+ handleToggleEnabled(deletingProvider)
+ }}
+ >
+ Deactivate
+
+
+
+
+
+ )
+}
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/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]/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/[team]/recovery/page.tsx b/frontend/app/[team]/recovery/page.tsx
index 77e92243f..2d9b6bac5 100644
--- a/frontend/app/[team]/recovery/page.tsx
+++ b/frontend/app/[team]/recovery/page.tsx
@@ -4,11 +4,13 @@ import { Button } from '@/components/common/Button'
import { AccountPassword } from '@/components/onboarding/AccountPassword'
import { AccountSeedChecker } from '@/components/onboarding/AccountSeedChecker'
import { Step, Stepper } from '@/components/onboarding/Stepper'
-import { useContext, useEffect, useState } from 'react'
+import { useContext, useEffect, useMemo, useState } from 'react'
+import { FaEye, FaEyeSlash, FaShieldAlt } from 'react-icons/fa'
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 RecoverAccountKeyring from '@/graphql/mutations/auth/recoverAccountKeyring.gql'
+import { useSession } from '@/contexts/userContext'
import { toast } from 'react-toastify'
import { useRouter } from 'next/navigation'
import UserMenu from '@/components/UserMenu'
@@ -16,43 +18,70 @@ import { organisationContext } from '@/contexts/organisationContext'
import { KeyringContext } from '@/contexts/keyringContext'
import { Avatar } from '@/components/common/Avatar'
import { RoleLabel } from '@/components/users/RoleLabel'
-import { setDevicePassword } from '@/utils/localStorage'
+import { ToggleSwitch } from '@/components/common/ToggleSwitch'
+import { setDeviceKey, setMemberDeviceKey, getDeviceKey } from '@/utils/localStorage'
import {
organisationSeed,
organisationKeyring,
deviceVaultKey,
+ passwordAuthHash,
encryptAccountKeyring,
encryptAccountRecovery,
} from '@/utils/crypto'
+import { useUser } from '@/contexts/userContext'
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('')
const [savePassword, setSavePassword] = useState(true)
+ const [showPw, setShowPw] = useState(false)
const [step, setStep] = useState(0)
- const steps: Step[] = [
- {
- index: 0,
- name: 'Recovery phrase',
- icon: ,
- title: 'Recovery phrase',
- description: 'Please enter the your account recovery phrase in the correct order below.',
- },
- {
- index: 1,
- name: 'Sudo password',
- icon: ,
- title: 'Sudo password',
- description:
- "Please set up a strong 'sudo' password to continue. This will be used to encrypt keys and perform administrative tasks.",
- },
- ]
+ const isPasswordUser = user?.authMethod === 'password'
+
+ // Password users are always authenticated when they reach this page, so
+ // their deviceKey is already cached. Use it to rewrap the keyring and
+ // skip the password input — they don't need to re-prove identity (the
+ // mnemonic does that) or rotate their auth password.
+ //
+ // Snapshot at mount: handleAccountRecovery's success path may write a
+ // fresh deviceKey to localStorage, which would otherwise re-evaluate
+ // here mid-flow and shrink the `steps` array out from under the
+ // already-advanced `step` state.
+ const cachedDeviceKey = useMemo(
+ () => (isPasswordUser && user?.userId ? getDeviceKey(user.userId) : null),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [isPasswordUser, user?.userId]
+ )
+ const skipPasswordStep = !!cachedDeviceKey
+
+ const recoveryPhraseStep: Step = {
+ index: 0,
+ name: 'Recovery phrase',
+ icon: ,
+ title: 'Recovery phrase',
+ description: 'Please enter the your account recovery phrase in the correct order below.',
+ }
+ const passwordStep: Step = {
+ index: 1,
+ name: isPasswordUser ? 'Account password' : 'Sudo password',
+ icon: ,
+ title: isPasswordUser ? 'Account password' : 'Sudo password',
+ description: isPasswordUser
+ ? 'Enter your account password. This will be used to restore access to this account.'
+ : "Please set up a strong 'sudo' password to continue. This will be used to encrypt keys and perform administrative tasks.",
+ }
+
+ const steps: Step[] = skipPasswordStep
+ ? [recoveryPhraseStep]
+ : [recoveryPhraseStep, passwordStep]
const [updateWrappedSecrets] = useMutation(UpdateWrappedSecrets)
+ const [recoverAccountKeyring] = useMutation(RecoverAccountKeyring)
const router = useRouter()
@@ -68,44 +97,68 @@ export default function Recovery({ params }: { params: { team: string } }) {
} else setInputs(inputs.map((input: string, i: number) => (index === i ? newValue : input)))
}
- const handleAccountRecovery = () => {
- return new Promise<{ publicKey: string; encryptedKeyring: string }>((resolve, reject) => {
- setTimeout(async () => {
- const mnemonic = inputs.join(' ')
+ const handleAccountRecovery = async () => {
+ // Yield once so the toast spinner paints before the (synchronous-feeling)
+ // KDF + encryption work begins.
+ await new Promise((r) => setTimeout(r, 50))
- const accountSeed = await organisationSeed(mnemonic, org?.id!)
+ const mnemonic = inputs.join(' ')
- const accountKeyRing = await organisationKeyring(accountSeed)
- if (accountKeyRing.publicKey !== org?.identityKey) {
- toast.error('Incorrect account recovery key!')
- reject('Incorrect account recovery key')
- return
- }
+ const accountSeed = await organisationSeed(mnemonic, org?.id!)
+ const accountKeyRing = await organisationKeyring(accountSeed)
+ if (accountKeyRing.publicKey !== org?.identityKey) {
+ throw new Error('Incorrect account recovery key')
+ }
- const deviceKey = await deviceVaultKey(pw, session?.user?.email!)
- const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey)
- const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey)
+ const deviceKey = cachedDeviceKey ?? (await deviceVaultKey(pw, session?.user?.email!))
+ const encryptedKeyring = await encryptAccountKeyring(accountKeyRing, deviceKey)
+ const encryptedMnemonic = await encryptAccountRecovery(mnemonic, deviceKey)
- setKeyring(accountKeyRing)
+ // Three cases:
+ // 1. Password user without a cached key (forgot-password flow): rotate
+ // the auth password and rewrap the keyring atomically.
+ // 2. Password user with a cached key (rewrap-only flow): they're
+ // authenticated already and just need this org's keyring rebuilt
+ // from the mnemonic — skip the auth rotation.
+ // 3. SSO user: just rewrap.
+ if (isPasswordUser && !cachedDeviceKey) {
+ const authHash = await passwordAuthHash(pw, session?.user?.email!)
+ await recoverAccountKeyring({
+ variables: {
+ orgId: org!.id,
+ authHash,
+ identityKey: accountKeyRing.publicKey,
+ wrappedKeyring: encryptedKeyring,
+ wrappedRecovery: encryptedMnemonic,
+ },
+ })
+ } else {
+ await updateWrappedSecrets({
+ variables: {
+ orgId: org!.id,
+ identityKey: accountKeyRing.publicKey,
+ wrappedKeyring: encryptedKeyring,
+ wrappedRecovery: encryptedMnemonic,
+ },
+ })
+ }
- await updateWrappedSecrets({
- variables: {
- orgId: org!.id,
- wrappedKeyring: encryptedKeyring,
- wrappedRecovery: encryptedMnemonic,
- },
- })
+ setKeyring(accountKeyRing)
- if (savePassword) {
- setDevicePassword(org?.memberId!, pw)
- }
+ // Persist the deviceKey only when we just derived it. The cached-key
+ // path reused an existing entry, so there's nothing new to store.
+ if (!cachedDeviceKey && savePassword) {
+ if (isPasswordUser && user?.userId) {
+ setDeviceKey(user.userId, deviceKey)
+ } else if (org?.memberId) {
+ setMemberDeviceKey(org.memberId, deviceKey)
+ }
+ }
- resolve({
- publicKey: accountKeyRing.publicKey,
- encryptedKeyring,
- })
- }, 1000)
- })
+ return {
+ publicKey: accountKeyRing.publicKey,
+ encryptedKeyring,
+ }
}
const incrementStep = async (event: { preventDefault: () => void }) => {
@@ -113,7 +166,7 @@ export default function Recovery({ params }: { params: { team: string } }) {
if (step !== steps.length - 1) setStep(step + 1)
if (step === steps.length - 1) {
- if (pw !== pw2) {
+ if (!isPasswordUser && pw !== pw2) {
toast.error("Passwords don't match")
return false
}
@@ -121,8 +174,14 @@ export default function Recovery({ params }: { params: { team: string } }) {
.promise(handleAccountRecovery, {
pending: 'Recovering your account...',
success: 'Recovery complete!',
+ error: {
+ render({ data }: { data: any }) {
+ return data?.message || 'Recovery failed. Please try again.'
+ },
+ },
})
.then(() => router.push(`/${org!.name}`))
+ .catch(() => {})
}
}
@@ -142,12 +201,17 @@ export default function Recovery({ params }: { params: { team: string } }) {
-
+
Account Recovery
-
+
This wizard will help you restore access to your Phase Account. Please enter your
- recovery phrase below, and then set a new sudo password.
+ recovery phrase below
+ {skipPasswordStep
+ ? '.'
+ : isPasswordUser
+ ? ', and then enter your account password.'
+ : ', and then set a new sudo password.'}
)}
- {step === 1 && (
-
- )}
+ {step === 1 &&
+ (isPasswordUser ? (
+
+
+
+ Account password
+
+
+ setPw(e.target.value)}
+ type={showPw ? 'text' : 'password'}
+ required
+ className="w-full ph-no-capture"
+ autoFocus
+ />
+ setShowPw(!showPw)}
+ tabIndex={-1}
+ >
+ {showPw ? : }
+
+
+
+
+
+
+
+
+ Remember password on this device
+
+
+
setSavePassword(!savePassword)}
+ />
+
+
+ ) : (
+
+ ))}
diff --git a/frontend/app/[team]/settings/page.tsx b/frontend/app/[team]/settings/page.tsx
index 194701a69..af17be6d1 100644
--- a/frontend/app/[team]/settings/page.tsx
+++ b/frontend/app/[team]/settings/page.tsx
@@ -12,11 +12,12 @@ 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'
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/app/invite/[invite]/page.tsx b/frontend/app/invite/[invite]/page.tsx
index a086fbc3b..7cf0824a0 100644
--- a/frontend/app/invite/[invite]/page.tsx
+++ b/frontend/app/invite/[invite]/page.tsx
@@ -1,30 +1,32 @@
'use client'
import VerifyInvite from '@/graphql/queries/organisation/validateOrganisationInvite.gql'
+import VerifyPassword from '@/graphql/queries/auth/verifyPassword.gql'
import AcceptOrganisationInvite from '@/graphql/mutations/organisation/acceptInvite.gql'
import GetOrganisations from '@/graphql/queries/getOrganisations.gql'
import { useLazyQuery, useMutation } from '@apollo/client'
-import { HeroPattern } from '@/components/common/HeroPattern'
import { Button } from '@/components/common/Button'
import { FaArrowRight } from 'react-icons/fa'
import Loading from '@/app/loading'
import { useEffect, useState } from 'react'
import { Step, Stepper } from '@/components/onboarding/Stepper'
import { AccountPassword } from '@/components/onboarding/AccountPassword'
+import { AccountPasswordVerify } from '@/components/onboarding/AccountPasswordVerify'
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, 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,
organisationSeed,
organisationKeyring,
deviceVaultKey,
+ passwordAuthHash,
encryptAccountKeyring,
encryptAccountRecovery,
} from '@/utils/crypto'
@@ -49,10 +51,12 @@ const InvalidInvite = () => (
export default function Invite({ params }: { params: { invite: string } }) {
const [verifyInvite, { data, loading, called }] = useLazyQuery(VerifyInvite)
+ const [verifyPassword] = useLazyQuery(VerifyPassword, { fetchPolicy: 'no-cache' })
const [acceptInvite] = useMutation(AcceptOrganisationInvite)
const { data: session } = useSession()
+ const { user } = useUser()
const router = useRouter()
@@ -82,37 +86,45 @@ 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 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
+ // Password users re-enter their existing account password (validated
+ // server-side) so the deviceKey can't drift from the auth password.
+ // SSO users have no auth password and set a dedicated sudo password.
+ const isPasswordUser = user?.authMethod === 'password'
+
+ const sudoStep: Step = {
+ index: 0,
+ name: isPasswordUser ? 'Account password' : 'Sudo Password',
+ icon:
,
+ title: isPasswordUser ? 'Confirm your account password' : 'Set a sudo password',
+ description: isPasswordUser
+ ? 'Re-enter your account password to encrypt your organisation keys.'
+ : '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 +158,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 {
@@ -156,13 +175,23 @@ export default function Invite({ params }: { params: { invite: string } }) {
})
}
- const validateCurrentStep = () => {
- if (step === 0) {
- if (pw !== pw2) {
+ const validateCurrentStep = async () => {
+ // Password step only exists when not skipped, at index 0.
+ if (!skipSudoStep && step === 0) {
+ if (isPasswordUser) {
+ // Validate against the stored auth-hash so the deviceKey we
+ // derive is guaranteed to match the user's account password.
+ const authHash = await passwordAuthHash(pw, session?.user?.email!)
+ const { data: verifyData } = await verifyPassword({ variables: { authHash } })
+ if (!verifyData?.verifyPassword) {
+ errorToast('Incorrect password. Please enter your account password.')
+ return false
+ }
+ } else 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
}
@@ -172,7 +201,7 @@ export default function Invite({ params }: { params: { invite: string } }) {
const incrementStep = async (event: { preventDefault: () => void }) => {
event.preventDefault()
- const isFormValid = validateCurrentStep()
+ const isFormValid = await validateCurrentStep()
if (step !== steps.length - 1 && isFormValid) setStep(step + 1)
if (step === steps.length - 1 && isFormValid) {
toast
@@ -199,10 +228,10 @@ export default function Invite({ params }: { params: { invite: string } }) {
-
+
-
Welcome to Phase
+
Welcome to Phase
You have been invited by{' '}
@@ -276,8 +305,6 @@ export default function Invite({ params }: { params: { invite: string } }) {
return (
<>
-
-
{loading || !called ? (
@@ -295,18 +322,26 @@ export default function Invite({ params }: { params: { invite: string } }) {
- {step === 0 && (
-
- )}
-
- {step === 1 && (
+ {!skipSudoStep && step === 0 &&
+ (isPasswordUser ? (
+
+ ) : (
+
+ ))}
+
+ {step === steps.length - 1 && (
+
+
+
+ >
+ )
+}
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..ce8d13c93
--- /dev/null
+++ b/frontend/app/onboard/page.tsx
@@ -0,0 +1,448 @@
+'use client'
+
+import { Button } from '@/components/common/Button'
+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 { AccountPasswordVerify } from '@/components/onboarding/AccountPasswordVerify'
+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 VerifyPassword from '@/graphql/queries/auth/verifyPassword.gql'
+import { copyRecoveryKit, generateRecoveryPdf } from '@/utils/recovery'
+import { setDeviceKey, getDeviceKey, setMemberDeviceKey } from '@/utils/localStorage'
+import { LogoMark } from '@/components/common/LogoMark'
+import {
+ organisationSeed,
+ organisationKeyring,
+ deviceVaultKey,
+ passwordAuthHash,
+ 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 [verifyPassword] = useLazyQuery(VerifyPassword, { fetchPolicy: 'no-cache' })
+ const [isloading, setIsLoading] = useState