From 3f41a292bf019937e10d0fd3c1cbe7335903dace Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:09:40 +0100 Subject: [PATCH 1/6] fix(sso): prevent orphan org on first login and use correct IdP URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DB migration to skip auto-org creation in generate_org_on_user_create when the new user's email domain has an active SSO provider. Previously, auth.ts lazily creating the public.users row would trigger an unwanted personal org for brand-new SSO users. - Replace client-side SP metadata URL computation in SsoConfiguration.vue (which used VITE_SUPABASE_URL / custom domain) with a fetch to the existing /private/sso/sp-metadata endpoint, which derives ACS URL and Entity ID from SUPABASE_URL — the same value Supabase uses internally for SAML processing. - Add sp_metadata_url to sp-metadata.ts response to match the SpMetadata interface consumed by the frontend. Co-Authored-By: Claude Sonnet 4.6 --- .../organizations/SsoConfiguration.vue | 33 +++++++++++-------- .../_backend/private/sso/sp-metadata.ts | 4 ++- ...60317160518_sso_skip_org_on_sso_domain.sql | 28 ++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql diff --git a/src/components/organizations/SsoConfiguration.vue b/src/components/organizations/SsoConfiguration.vue index cc7a5a80aa..85b72e8d15 100644 --- a/src/components/organizations/SsoConfiguration.vue +++ b/src/components/organizations/SsoConfiguration.vue @@ -6,7 +6,7 @@ import IconCopy from '~icons/heroicons/document-duplicate' import IconGlobeAlt from '~icons/heroicons/globe-alt' import IconTrash from '~icons/heroicons/trash' import Spinner from '~/components/Spinner.vue' -import { defaultApiHost, getSupabaseHost, useSupabase } from '~/services/supabase' +import { defaultApiHost, useSupabase } from '~/services/supabase' import { useDialogV2Store } from '~/stores/dialogv2' interface SsoProvider { @@ -38,18 +38,25 @@ interface SpMetadata { } const providers = ref([]) -const spMetadata = computed(() => { - const base = getSupabaseHost() - if (!base) - return null - const metadataUrl = `${base}/auth/v1/sso/saml/metadata` - return { - acs_url: `${base}/auth/v1/sso/saml/acs`, - entity_id: metadataUrl, - sp_metadata_url: metadataUrl, - nameid_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', +const spMetadata = ref(null) + +async function fetchSpMetadata() { + try { + const headers = await getAuthHeaders() + const response = await fetch(`${defaultApiHost}/private/sso/sp-metadata`, { + method: 'GET', + headers, + }) + if (!response.ok) { + console.error('Failed to fetch SP metadata:', response.status) + return + } + spMetadata.value = await response.json() as SpMetadata } -}) + catch (error) { + console.error('Error fetching SP metadata:', error) + } +} const isLoading = ref(true) const isSubmitting = ref(false) const isVerifying = ref(null) @@ -336,7 +343,7 @@ function formatDate(dateString: string): string { } onMounted(async () => { - await fetchProviders() + await Promise.all([fetchProviders(), fetchSpMetadata()]) }) // Expose showAddForm so parent can control it diff --git a/supabase/functions/_backend/private/sso/sp-metadata.ts b/supabase/functions/_backend/private/sso/sp-metadata.ts index 98f82806e0..5657317f5c 100644 --- a/supabase/functions/_backend/private/sso/sp-metadata.ts +++ b/supabase/functions/_backend/private/sso/sp-metadata.ts @@ -15,9 +15,11 @@ app.get('/', (c) => { const supabaseUrl = getEnv(c, 'SUPABASE_URL').replace(/\/$/, '') + const metadataUrl = `${supabaseUrl}/auth/v1/sso/saml/metadata` return c.json({ acs_url: `${supabaseUrl}/auth/v1/sso/saml/acs`, - entity_id: `${supabaseUrl}/auth/v1/sso/saml/metadata`, + entity_id: metadataUrl, + sp_metadata_url: metadataUrl, nameid_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', }) }) diff --git a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql new file mode 100644 index 0000000000..528845ea91 --- /dev/null +++ b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql @@ -0,0 +1,28 @@ +-- Fix: prevent auto-org creation for users whose email domain has an active SSO provider. +-- When a new SSO user logs in, auth.ts lazily creates a public.users row which fires +-- generate_org_on_user_create. For SSO domains, provision-user.ts assigns the correct org, +-- so this auto-created personal org is unwanted. Skip it when an active SSO provider exists +-- for the user's domain. + +CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create" () RETURNS "trigger" LANGUAGE "plpgsql" +SET + search_path = '' SECURITY DEFINER AS $$ +DECLARE + org_record record; + has_sso boolean; +BEGIN + -- Skip auto-org for SSO-managed domains; provision-user.ts assigns the correct org + SELECT EXISTS ( + SELECT 1 FROM public.sso_providers + WHERE domain = lower(split_part(NEW.email, '@', 2)) + AND status = 'active' + ) INTO has_sso; + + IF has_sso THEN + RETURN NEW; + END IF; + + INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; + + RETURN NEW; +END $$; From 17170d15d2138dfae1716d58c4ef8c07cebf02b3 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:10:24 +0100 Subject: [PATCH 2/6] chore: update bun lockfile after install Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/bun.lock b/bun.lock index 2bf8cd9874..249030b503 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "capgo-app", From 08c6794cb73bffc4933ed49c865d02f5d0e00a43 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:18:44 +0100 Subject: [PATCH 3/6] fix(sso): harden trigger and sp-metadata after security audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate_org_on_user_create: add auth.users provider check to avoid suppressing personal org for email/password signups on SSO domains (Finding 2 — High). Email/password users on a corporate domain still get a personal org; provision-user.ts rejects them anyway. - generate_org_on_user_create: add sso_enabled org flag to EXISTS query, matching check_domain_sso and all other SSO lookups (Finding 1 — Critical). - generate_org_on_user_create: apply btrim to domain component to match the lower(btrim(domain)) normalization contract from migration 20260312183000 (Finding 6 — Medium). - sp-metadata.ts: add return before quickError to prevent silent fall-through if quickError signature ever changes (Finding 3 — High). Co-Authored-By: Claude Sonnet 4.6 --- .../_backend/private/sso/sp-metadata.ts | 2 +- ...60317160518_sso_skip_org_on_sso_domain.sql | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/supabase/functions/_backend/private/sso/sp-metadata.ts b/supabase/functions/_backend/private/sso/sp-metadata.ts index 5657317f5c..69cbbca65f 100644 --- a/supabase/functions/_backend/private/sso/sp-metadata.ts +++ b/supabase/functions/_backend/private/sso/sp-metadata.ts @@ -10,7 +10,7 @@ app.use('*', middlewareAuth) app.get('/', (c) => { const auth = c.get('auth') if (!auth) { - quickError(401, 'not_authorized', 'Not authorized') + return quickError(401, 'not_authorized', 'Not authorized') } const supabaseUrl = getEnv(c, 'SUPABASE_URL').replace(/\/$/, '') diff --git a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql index 528845ea91..a0a76dba7c 100644 --- a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql +++ b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql @@ -3,6 +3,14 @@ -- generate_org_on_user_create. For SSO domains, provision-user.ts assigns the correct org, -- so this auto-created personal org is unwanted. Skip it when an active SSO provider exists -- for the user's domain. +-- +-- Only skips org creation when: +-- 1. The user authenticated via SSO (provider != 'email') — prevents email/password signups +-- with a corporate domain from being left in a broken no-org state. +-- 2. The domain has an active SSO provider AND the owning org has sso_enabled = true — +-- consistent with check_domain_sso and all other SSO lookups in the system. +-- 3. btrim applied to the domain component — matches the normalization contract from +-- migration 20260312183000 which enforces lower(btrim(domain)). CREATE OR REPLACE FUNCTION "public"."generate_org_on_user_create" () RETURNS "trigger" LANGUAGE "plpgsql" SET @@ -10,12 +18,28 @@ SET DECLARE org_record record; has_sso boolean; + user_provider text; BEGIN - -- Skip auto-org for SSO-managed domains; provision-user.ts assigns the correct org + -- Only suppress org creation for users who actually authenticated via SSO. + -- Email/password signups with a corporate domain should still get a personal org; + -- provision-user.ts will reject them anyway (requires SSO identity). + SELECT raw_app_meta_data->>'provider' + INTO user_provider + FROM auth.users + WHERE id = NEW.id; + + IF user_provider IS NULL OR user_provider = 'email' OR user_provider = 'phone' THEN + INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; + RETURN NEW; + END IF; + + -- Skip auto-org for SSO-managed domains; provision-user.ts assigns the correct org. + -- Mirror the sso_enabled guard from check_domain_sso to stay consistent. SELECT EXISTS ( - SELECT 1 FROM public.sso_providers - WHERE domain = lower(split_part(NEW.email, '@', 2)) - AND status = 'active' + SELECT 1 FROM public.sso_providers sp + JOIN public.orgs o ON o.id = sp.org_id AND o.sso_enabled = true + WHERE sp.domain = lower(btrim(split_part(NEW.email, '@', 2))) + AND sp.status = 'active' ) INTO has_sso; IF has_sso THEN From adedfe2619b77888fd4f7a8eabca3f0238550f90 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:25:40 +0100 Subject: [PATCH 4/6] fix(sso): surface toast on SP metadata fetch failures Both error paths in fetchSpMetadata (non-ok response and network error) now show a toast.error alongside the existing console.error, consistent with all other error handling in SsoConfiguration.vue. Co-Authored-By: Claude Sonnet 4.6 --- messages/de.json | 1 + messages/en.json | 2 +- messages/es.json | 1 + messages/fr.json | 1 + messages/hi.json | 1 + messages/id.json | 1 + messages/it.json | 1 + messages/ja.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + messages/pt-br.json | 1 + messages/ru.json | 1 + messages/tr.json | 1 + messages/vi.json | 1 + messages/zh-cn.json | 1 + src/components/organizations/SsoConfiguration.vue | 2 ++ 16 files changed, 17 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index af22050412..a634204a3b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "SSO-Anbieter konnte nicht erstellt werden", "sso-error-deleting": "SSO-Anbieter konnte nicht gelöscht werden", "sso-error-loading": "SSO-Anbieter konnten nicht geladen werden", + "sso-error-loading-sp-metadata": "SP-Metadaten konnten nicht geladen werden", "sso-error-updating": "Aktualisierung des SSO-Anbieters fehlgeschlagen", "sso-fill-all-fields": "Bitte füllen Sie alle erforderlichen Felder aus", "sso-metadata-description": "Geben Sie diese Werte Ihrem Identity Provider (IdP) bei der Konfiguration von SAML SSO an.", diff --git a/messages/en.json b/messages/en.json index 931449bdac..9a7321879e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1463,6 +1463,7 @@ "sso-error-creating": "Failed to create SSO provider", "sso-error-deleting": "Failed to delete SSO provider", "sso-error-loading": "Failed to load SSO providers", + "sso-error-loading-sp-metadata": "Failed to load SP metadata", "sso-fill-all-fields": "Please fill in all required fields", "sso-metadata-url": "SAML Metadata URL", "sso-metadata-url-help": "The SAML metadata URL from your identity provider", @@ -1478,7 +1479,6 @@ "sso-status-verified": "Verified", "sso-verify-dns": "Verify DNS", "sso-verifying": "Verifying...", - "sso-enterprise-required": "Enterprise Plan Required", "sso-enterprise-required-description": "SSO configuration is only available for Enterprise plan customers. Please upgrade your plan to enable Single Sign-On.", "sso-enterprise-upgrade-description": "SAML 2.0 Single Sign-On is available exclusively on the Enterprise plan. Upgrade to enable SSO for your organization.", diff --git a/messages/es.json b/messages/es.json index 44a4dff9ba..08f56371e1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Error al crear el proveedor de SSO", "sso-error-deleting": "Error al eliminar el proveedor de SSO", "sso-error-loading": "Error al cargar los proveedores de SSO", + "sso-error-loading-sp-metadata": "Error al cargar los metadatos del SP", "sso-error-updating": "Error al actualizar el proveedor SSO", "sso-fill-all-fields": "Por favor, complete todos los campos obligatorios", "sso-metadata-description": "Proporcione estos valores a su proveedor de identidad (IdP) al configurar SAML SSO.", diff --git a/messages/fr.json b/messages/fr.json index cd695cc1b8..0710835130 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Échec de la création du fournisseur SSO", "sso-error-deleting": "Échec de la suppression du fournisseur SSO", "sso-error-loading": "Échec du chargement des fournisseurs SSO", + "sso-error-loading-sp-metadata": "Impossible de charger les métadonnées SP", "sso-error-updating": "Échec de la mise à jour du fournisseur SSO", "sso-fill-all-fields": "Veuillez remplir tous les champs obligatoires", "sso-metadata-description": "Fournissez ces valeurs à votre fournisseur d'identité (IdP) lors de la configuration du SSO SAML.", diff --git a/messages/hi.json b/messages/hi.json index 5542400273..ac6adb5ca5 100644 --- a/messages/hi.json +++ b/messages/hi.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "SSO प्रदाता बनाने में विफल", "sso-error-deleting": "SSO प्रदाता को हटाने में विफल", "sso-error-loading": "SSO प्रदाताओं को लोड करने में विफल", + "sso-error-loading-sp-metadata": "SP मेटाडेटा लोड करने में विफल", "sso-error-updating": "SSO provider को अपडेट करने में विफल", "sso-fill-all-fields": "कृपया सभी आवश्यक फ़ील्ड भरें", "sso-metadata-description": "SAML SSO कॉन्फ़िगर करते समय ये मान अपने Identity Provider (IdP) को प्रदान करें।", diff --git a/messages/id.json b/messages/id.json index 897b73a8d9..af9287e23d 100644 --- a/messages/id.json +++ b/messages/id.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Gagal membuat penyedia SSO", "sso-error-deleting": "Gagal menghapus penyedia SSO", "sso-error-loading": "Gagal memuat penyedia SSO", + "sso-error-loading-sp-metadata": "Gagal memuat metadata SP", "sso-error-updating": "Gagal memperbarui penyedia SSO", "sso-fill-all-fields": "Harap isi semua bidang yang diperlukan", "sso-metadata-description": "Berikan nilai-nilai ini ke Penyedia Identitas (IdP) Anda saat mengonfigurasi SAML SSO.", diff --git a/messages/it.json b/messages/it.json index c48d8cb75f..ddd920d4f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Impossibile creare il fornitore SSO", "sso-error-deleting": "Impossibile eliminare il fornitore SSO", "sso-error-loading": "Impossibile caricare i fornitori SSO", + "sso-error-loading-sp-metadata": "Impossibile caricare i metadati SP", "sso-error-updating": "Impossibile aggiornare il provider SSO", "sso-fill-all-fields": "Si prega di compilare tutti i campi obbligatori", "sso-metadata-description": "Fornisci questi valori al tuo Identity Provider (IdP) durante la configurazione di SAML SSO.", diff --git a/messages/ja.json b/messages/ja.json index c67d9ba5fa..a3caefb8b5 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "SSOプロバイダーの作成に失敗しました", "sso-error-deleting": "SSOプロバイダーの削除に失敗しました", "sso-error-loading": "SSOプロバイダーの読み込みに失敗しました", + "sso-error-loading-sp-metadata": "SPメタデータの読み込みに失敗しました", "sso-error-updating": "SSOプロバイダーの更新に失敗しました", "sso-fill-all-fields": "すべての必須項目を入力してください", "sso-metadata-description": "SAML SSOを設定する際、これらの値をIDプロバイダー(IdP)に提供してください。", diff --git a/messages/ko.json b/messages/ko.json index 686cd17d75..206c27cc4d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "SSO 공급자 생성 실패", "sso-error-deleting": "SSO 공급자 삭제 실패", "sso-error-loading": "SSO 공급자 로드 실패", + "sso-error-loading-sp-metadata": "SP 메타데이터 로드 실패", "sso-error-updating": "SSO 공급자 업데이트에 실패했습니다", "sso-fill-all-fields": "모든 필수 필드를 채워주세요", "sso-metadata-description": "SAML SSO를 구성할 때 이 값들을 ID 공급자(IdP)에 제공하세요.", diff --git a/messages/pl.json b/messages/pl.json index 923f428436..63fb02ac64 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Nie udało się utworzyć dostawcy SSO", "sso-error-deleting": "Nie udało się usunąć dostawcy SSO", "sso-error-loading": "Nie udało się załadować dostawców SSO", + "sso-error-loading-sp-metadata": "Nie udało się załadować metadanych SP", "sso-error-updating": "Nie udało się zaktualizować dostawcy SSO", "sso-fill-all-fields": "Proszę wypełnić wszystkie wymagane pola", "sso-metadata-description": "Podaj te wartości swojemu dostawcy tożsamości (IdP) podczas konfigurowania SAML SSO.", diff --git a/messages/pt-br.json b/messages/pt-br.json index b8a0a5e874..cfe357efa7 100644 --- a/messages/pt-br.json +++ b/messages/pt-br.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Falha ao criar provedor SSO", "sso-error-deleting": "Falha ao excluir provedor SSO", "sso-error-loading": "Falha ao carregar provedores SSO", + "sso-error-loading-sp-metadata": "Falha ao carregar metadados do SP", "sso-error-updating": "Falha ao atualizar o provedor SSO", "sso-fill-all-fields": "Por favor, preencha todos os campos obrigatórios", "sso-metadata-description": "Forneça estes valores ao seu Provedor de Identidade (IdP) ao configurar o SAML SSO.", diff --git a/messages/ru.json b/messages/ru.json index f503d581fa..14766d1907 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Не удалось создать провайдера SSO", "sso-error-deleting": "Не удалось удалить провайдера SSO", "sso-error-loading": "Не удалось загрузить провайдеров SSO", + "sso-error-loading-sp-metadata": "Не удалось загрузить метаданные SP", "sso-error-updating": "Не удалось обновить поставщика SSO", "sso-fill-all-fields": "Пожалуйста, заполните все обязательные поля", "sso-metadata-description": "Предоставьте эти значения вашему поставщику удостоверений (IdP) при настройке SAML SSO.", diff --git a/messages/tr.json b/messages/tr.json index 0d350bc80c..d1334c6661 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "SSO sağlayıcısı oluşturulamadı", "sso-error-deleting": "SSO sağlayıcısı silinemedi", "sso-error-loading": "SSO sağlayıcıları yüklenemedi", + "sso-error-loading-sp-metadata": "SP meta verileri yüklenemedi", "sso-error-updating": "SSO sağlayıcısı güncellenemedi", "sso-fill-all-fields": "Lütfen tüm gerekli alanları doldurun", "sso-metadata-description": "SAML SSO yapılandırırken bu değerleri Kimlik Sağlayıcınıza (IdP) sağlayın.", diff --git a/messages/vi.json b/messages/vi.json index 06f3b4e2af..927ce4a738 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "Tạo nhà cung cấp SSO thất bại", "sso-error-deleting": "Xóa nhà cung cấp SSO thất bại", "sso-error-loading": "Tải nhà cung cấp SSO thất bại", + "sso-error-loading-sp-metadata": "Tải siêu dữ liệu SP thất bại", "sso-error-updating": "Không thể cập nhật nhà cung cấp SSO", "sso-fill-all-fields": "Vui lòng điền vào tất cả các trường bắt buộc", "sso-metadata-description": "Cung cấp các giá trị này cho Nhà cung cấp Danh tính (IdP) của bạn khi cấu hình SAML SSO.", diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 03ab1ec848..27ed5f1190 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -1478,6 +1478,7 @@ "sso-error-creating": "创建 SSO 提供商失败", "sso-error-deleting": "删除 SSO 提供商失败", "sso-error-loading": "加载 SSO 提供商失败", + "sso-error-loading-sp-metadata": "加载 SP 元数据失败", "sso-error-updating": "更新SSO提供商失败", "sso-fill-all-fields": "请填写所有必填字段", "sso-metadata-description": "配置SAML SSO时,请将这些值提供给您的身份提供商(IdP)。", diff --git a/src/components/organizations/SsoConfiguration.vue b/src/components/organizations/SsoConfiguration.vue index 85b72e8d15..f4c54ab986 100644 --- a/src/components/organizations/SsoConfiguration.vue +++ b/src/components/organizations/SsoConfiguration.vue @@ -49,12 +49,14 @@ async function fetchSpMetadata() { }) if (!response.ok) { console.error('Failed to fetch SP metadata:', response.status) + toast.error(t('sso-error-loading-sp-metadata')) return } spMetadata.value = await response.json() as SpMetadata } catch (error) { console.error('Error fetching SP metadata:', error) + toast.error(t('sso-error-loading-sp-metadata')) } } const isLoading = ref(true) From aae47d9989f5c79a79f2ec2a07aa0a8dffa4e600 Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:34:30 +0100 Subject: [PATCH 5/6] fix(sso): add structured cloudlog to sp-metadata unauthorized branch Add Context type annotation, cloudlog import, and requestId to the app.get handler. The !auth branch now emits a cloudlog entry with requestId, message, and auth value before returning quickError, consistent with the logging pattern in provision-user.ts. Co-Authored-By: Claude Sonnet 4.6 --- supabase/functions/_backend/private/sso/sp-metadata.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_backend/private/sso/sp-metadata.ts b/supabase/functions/_backend/private/sso/sp-metadata.ts index 69cbbca65f..3b522e0e86 100644 --- a/supabase/functions/_backend/private/sso/sp-metadata.ts +++ b/supabase/functions/_backend/private/sso/sp-metadata.ts @@ -1,4 +1,7 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import { createHono, middlewareAuth, quickError, useCors } from '../../utils/hono.ts' +import { cloudlog } from '../../utils/logging.ts' import { getEnv } from '../../utils/utils.ts' import { version } from '../../utils/version.ts' @@ -7,9 +10,11 @@ export const app = createHono('', version) app.use('*', useCors) app.use('*', middlewareAuth) -app.get('/', (c) => { +app.get('/', (c: Context) => { const auth = c.get('auth') + const requestId = c.get('requestId') if (!auth) { + cloudlog({ requestId, message: 'Unauthorized request to sp-metadata — no auth context', auth }) return quickError(401, 'not_authorized', 'Not authorized') } From 6e9da00126340bb007c44b1a34b2d82f7f931a4f Mon Sep 17 00:00:00 2001 From: Jordan Lorho Date: Tue, 17 Mar 2026 16:48:40 +0100 Subject: [PATCH 6/6] fix(sso): restrict org-skip to SAML providers only in trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous logic checked has_sso AFTER the provider IF, and skipped org creation for any non-email/phone provider when has_sso was true — incorrectly affecting OAuth users (google, github, etc.) whose email domain happened to match an active SSO provider. Restructure: compute has_sso first, then use a single condition NOT (user_provider ~ '^sso:' AND has_sso) to gate the INSERT. Supabase sets app_metadata.provider to 'sso:' for SAML sessions, so OAuth providers never match '^sso:' and always get a personal org. This also eliminates the duplicate INSERT path. Co-Authored-By: Claude Sonnet 4.6 --- ...60317160518_sso_skip_org_on_sso_domain.sql | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql index a0a76dba7c..bda5e9770a 100644 --- a/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql +++ b/supabase/migrations/20260317160518_sso_skip_org_on_sso_domain.sql @@ -20,20 +20,12 @@ DECLARE has_sso boolean; user_provider text; BEGIN - -- Only suppress org creation for users who actually authenticated via SSO. - -- Email/password signups with a corporate domain should still get a personal org; - -- provision-user.ts will reject them anyway (requires SSO identity). SELECT raw_app_meta_data->>'provider' INTO user_provider FROM auth.users WHERE id = NEW.id; - IF user_provider IS NULL OR user_provider = 'email' OR user_provider = 'phone' THEN - INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; - RETURN NEW; - END IF; - - -- Skip auto-org for SSO-managed domains; provision-user.ts assigns the correct org. + -- Compute has_sso first so it can be combined with the provider check below. -- Mirror the sso_enabled guard from check_domain_sso to stay consistent. SELECT EXISTS ( SELECT 1 FROM public.sso_providers sp @@ -42,11 +34,13 @@ BEGIN AND sp.status = 'active' ) INTO has_sso; - IF has_sso THEN - RETURN NEW; + -- Skip org creation only for genuine SAML SSO logins on SSO-managed domains. + -- Supabase sets app_metadata.provider to 'sso:' for SAML sessions. + -- Email, phone, and OAuth providers (e.g. google, github) always get a personal org, + -- even when their email domain matches an active SSO provider. + IF NOT (user_provider ~ '^sso:' AND has_sso) THEN + INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; END IF; - INSERT INTO public.orgs (created_by, name, management_email) values (NEW.id, format('%s organization', NEW.first_name), NEW.email) RETURNING * INTO org_record; - RETURN NEW; END $$;