diff --git a/src/composables/useSSOProvisioning.ts b/src/composables/useSSOProvisioning.ts index 80349f5bae..9a19d3ceed 100644 --- a/src/composables/useSSOProvisioning.ts +++ b/src/composables/useSSOProvisioning.ts @@ -1,6 +1,6 @@ import type { Session } from '@supabase/supabase-js' import { ref } from 'vue' -import { defaultApiHost, useSupabase } from '~/services/supabase' +import { provisionSsoUser } from '~/services/ssoProvisioning' export function useSSOProvisioning() { const isProvisioning = ref(false) @@ -11,63 +11,14 @@ export function useSSOProvisioning() { error.value = null try { - const supabase = useSupabase() - const userId = session.user.id - const email = session.user.email - - if (!email) { - error.value = 'No email found in session' - return { merged: false, alreadyMember: false } - } - - // Check if user has a public.users record - // Server-side auth triggers handle creating this, but we verify - const { data: userRecord, error: userError } = await supabase - .from('users') - .select('id') - .eq('id', userId) - .maybeSingle() - - if (userError) { - console.error('SSO provisioning: failed to check user record (non-blocking)', userError) - } - - if (!userRecord) { - // User record not yet created by auth trigger — wait briefly and recheck - // The backend handles this via auth triggers, so we just log and continue - console.log('SSO provisioning: user record not yet created, backend trigger will handle it') - } - - // Always call the server-side provisioning endpoint — it resolves the - // SSO provider org from the user's email domain and checks membership - // against that specific org. A client-side check would be too broad - // (the user may belong to a different org but not the SSO target org). - try { - const provisionResponse = await fetch(`${defaultApiHost}/private/sso/provision-user`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }) - - if (!provisionResponse.ok) { - const errorData = await provisionResponse.json().catch(() => ({ error: 'Unknown error' })) as Record - console.error('SSO provisioning: provision request failed', provisionResponse.status, errorData) - const errorMsg = typeof errorData.error === 'string' ? errorData.error : typeof errorData.message === 'string' ? errorData.message : null - error.value = errorMsg ?? `Provisioning failed (${provisionResponse.status})` - return { merged: false, alreadyMember: false } - } - - const provisionData = await provisionResponse.json() as { success: boolean, merged?: boolean, already_member?: boolean } - console.log('SSO provisioning: user provisioned successfully', provisionData) - return { merged: provisionData.merged === true, alreadyMember: provisionData.already_member === true } + const result = await provisionSsoUser(session) + if (result.error) { + console.error('SSO provisioning failed', result.error) + error.value = result.error } - catch (provisionError) { - console.error('SSO provisioning: provision request error', provisionError) - error.value = provisionError instanceof Error ? provisionError.message : 'Provisioning request failed' - return { merged: false, alreadyMember: false } + return { + merged: result.merged, + alreadyMember: result.alreadyMember, } } catch (err) { diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 407f12e966..cf11d6cacd 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -3,6 +3,7 @@ import type { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' import type { UserModule } from '~/types' import { hideLoader } from '~/services/loader' import { setUser } from '~/services/posthog' +import { isSsoUser, provisionSsoUser } from '~/services/ssoProvisioning' import { createSignedImageUrl } from '~/services/storage' import { getLocalConfig, useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' @@ -82,6 +83,33 @@ async function updateUser( } } +async function maybeProvisionSsoMembership( + supabase: SupabaseClient, + session: Awaited>['data']['session'] | null, +): Promise<'continue' | 'redirect_login' | 'abort_navigation'> { + if (!session || !isSsoUser(session.user)) + return 'continue' + + const result = await provisionSsoUser(session) + + if (result.merged) { + const { error: signOutError } = await supabase.auth.signOut() + if (signOutError) { + console.error('Failed to sign out merged SSO session during auth guard:', signOutError) + return 'abort_navigation' + } + + return 'redirect_login' + } + + if (result.error) { + console.error('Failed to provision SSO membership during auth guard:', result.error) + return 'abort_navigation' + } + + return 'continue' +} + async function guard( next: NavigationGuardNext, to: RouteLocationNormalized, @@ -180,6 +208,15 @@ async function guard( console.error('Error checking if account is disabled:', error) } + const provisioningResult = await maybeProvisionSsoMembership(supabase, sessionData?.session ?? null) + if (provisioningResult === 'redirect_login') { + return next('/login?message=sso_account_linked') + } + if (provisioningResult === 'abort_navigation') { + hideLoader() + return next(false) + } + if (!main.user) { await updateUser(main, supabase) } @@ -270,7 +307,20 @@ async function guard( } } - const organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations()) + let organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations()) + if (organizationsLoaded && !organizationStore.hasOrganizations && isSsoUser(sessionUser)) { + const didProvisionSsoMembership = await maybeProvisionSsoMembership(supabase, sessionData?.session ?? null) + if (didProvisionSsoMembership === 'redirect_login') { + return next('/login?message=sso_account_linked') + } + if (didProvisionSsoMembership === 'abort_navigation') { + hideLoader() + return next(false) + } + + organizationsLoaded = await tryLoadOrganizations(() => organizationStore.fetchOrganizations()) + } + if (organizationsLoaded && !organizationStore.hasOrganizations && shouldRedirectToOrgOnboarding()) { return next('/onboarding/organization') } diff --git a/src/services/ssoProvisioning.ts b/src/services/ssoProvisioning.ts new file mode 100644 index 0000000000..3d43fe719f --- /dev/null +++ b/src/services/ssoProvisioning.ts @@ -0,0 +1,68 @@ +import type { Session, User } from '@supabase/supabase-js' +import { defaultApiHost } from '~/services/supabase' + +export interface SsoProvisioningResult { + merged: boolean + alreadyMember: boolean + error: string | null +} + +function isSsoProvider(provider: string | undefined): boolean { + return !!provider && (provider === 'sso' || provider.startsWith('sso:')) +} + +export function isSsoUser(user: Pick | null | undefined): boolean { + const provider = typeof user?.app_metadata?.provider === 'string' ? user.app_metadata.provider : undefined + const providers = Array.isArray(user?.app_metadata?.providers) + ? user.app_metadata.providers.filter((item): item is string => typeof item === 'string') + : [] + + return isSsoProvider(provider) || providers.some(isSsoProvider) +} + +export async function provisionSsoUser(session: Session): Promise { + try { + const response = await fetch(`${defaultApiHost}/private/sso/provision-user`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: 'Unknown error' })) as Record + const errorMessage = typeof errorData.error === 'string' + ? errorData.error + : typeof errorData.message === 'string' + ? errorData.message + : `Provisioning failed (${response.status})` + + return { + merged: false, + alreadyMember: false, + error: errorMessage, + } + } + + const provisionData = await response.json() as { + success: boolean + merged?: boolean + already_member?: boolean + } + + return { + merged: provisionData.merged === true, + alreadyMember: provisionData.already_member === true, + error: null, + } + } + catch (error) { + return { + merged: false, + alreadyMember: false, + error: error instanceof Error ? error.message : 'Provisioning request failed', + } + } +} diff --git a/supabase/functions/_backend/private/sso/provision-user.ts b/supabase/functions/_backend/private/sso/provision-user.ts index d45bd3a5b1..968d928287 100644 --- a/supabase/functions/_backend/private/sso/provision-user.ts +++ b/supabase/functions/_backend/private/sso/provision-user.ts @@ -13,6 +13,22 @@ interface PublicUserSeed { last_name: string | null } +type OrgMembershipRight + = 'read' + | 'upload' + | 'write' + | 'admin' + | 'super_admin' + | 'invite_read' + | 'invite_upload' + | 'invite_write' + | 'invite_admin' + | 'invite_super_admin' + +interface EnsureOrgMembershipResult { + alreadyMember: boolean +} + export const app = createHono('', version) app.use('*', useCors) @@ -102,6 +118,89 @@ function buildPublicUserSeed(userId: string, email: string, userMetadata: Record } } +function isInviteRole(role: string | null | undefined): role is Extract { + return !!role && role.startsWith('invite_') +} + +function promoteInviteRole(role: Extract): Exclude { + switch (role) { + case 'invite_read': + return 'read' + case 'invite_upload': + return 'upload' + case 'invite_write': + return 'write' + case 'invite_admin': + return 'admin' + case 'invite_super_admin': + return 'super_admin' + } +} + +async function ensureOrgMembership( + admin: ReturnType, + requestId: string, + userId: string, + orgId: string, + fallbackRole: Exclude = 'read', + allowRetry = true, +): Promise { + const { data: existingMembership, error: membershipCheckError } = await (admin as any) + .from('org_users') + .select('id, user_right') + .eq('user_id', userId) + .eq('org_id', orgId) + .maybeSingle() + + if (membershipCheckError) { + cloudlogErr({ requestId, message: 'Failed to check existing org membership', userId, orgId, error: membershipCheckError }) + throw new Error('membership_check_failed') + } + + const currentRight = typeof existingMembership?.user_right === 'string' + ? existingMembership.user_right as OrgMembershipRight + : null + + if (existingMembership) { + if (!isInviteRole(currentRight)) { + return { alreadyMember: true } + } + + const promotedRole = promoteInviteRole(currentRight) + const { error: promotionError } = await (admin as any) + .from('org_users') + .update({ user_right: promotedRole }) + .eq('id', existingMembership.id) + + if (promotionError) { + cloudlogErr({ requestId, message: 'Failed to promote invited org membership during SSO provisioning', userId, orgId, fromRole: currentRight, toRole: promotedRole, error: promotionError }) + throw new Error('membership_promotion_failed') + } + + return { alreadyMember: false } + } + + const { error: insertError } = await (admin as any) + .from('org_users') + .insert({ + user_id: userId, + org_id: orgId, + user_right: fallbackRole, + }) + + if (!insertError) { + return { alreadyMember: false } + } + + const isDuplicate = insertError.code === '23505' || insertError.message?.toLowerCase().includes('duplicate') + if (isDuplicate && allowRetry) { + return ensureOrgMembership(admin, requestId, userId, orgId, fallbackRole, false) + } + + cloudlogErr({ requestId, message: 'Failed to insert user into org_users during SSO provisioning', userId, orgId, fallbackRole, error: insertError }) + throw new Error('membership_insert_failed') +} + async function ensurePublicUserRowExists( admin: ReturnType, requestId: string, @@ -261,42 +360,33 @@ app.post('/', async (c: Context) => { .select('id, org_id, enforce_sso') .eq('domain', userDomain) .eq('status', 'active') - .single() + .maybeSingle() if (mergeProviderError) { - cloudlogErr({ requestId, message: 'Failed to resolve SSO provider during merge — skipping org membership insert', originalUserId, domain: userDomain, error: mergeProviderError }) + cloudlogErr({ requestId, message: 'Failed to resolve SSO provider during merge', originalUserId, domain: userDomain, error: mergeProviderError }) + return quickError(500, 'provider_lookup_failed', 'Failed to resolve SSO provider for your email domain') } - if (!mergeProviderError && mergeProvider) { - try { - await ensurePublicUserRowExists(admin, requestId, { - ...publicUserSeed, - id: originalUserId, - }) - } - catch { - return quickError(500, 'public_user_sync_failed', 'Failed to create user profile for merged SSO account') - } + if (!mergeProvider) { + cloudlogErr({ requestId, message: 'No active SSO provider found during merge', originalUserId, domain: userDomain }) + return quickError(404, 'provider_not_found', 'No active SSO provider found for your email domain') + } - const { data: existingMembership } = await (admin as any) - .from('org_users') - .select('id') - .eq('user_id', originalUserId) - .eq('org_id', mergeProvider.org_id) - .maybeSingle() - - if (!existingMembership) { - const { error: mergeInsertError } = await (admin as any) - .from('org_users') - .insert({ user_id: originalUserId, org_id: mergeProvider.org_id, user_right: 'read' }) - - if (mergeInsertError) { - const isDuplicate = mergeInsertError.code === '23505' || mergeInsertError.message?.toLowerCase().includes('duplicate') - if (!isDuplicate) { - cloudlogErr({ requestId, message: 'Failed to insert original user into org_users during merge', originalUserId, orgId: mergeProvider.org_id, error: mergeInsertError }) - } - } - } + try { + await ensurePublicUserRowExists(admin, requestId, { + ...publicUserSeed, + id: originalUserId, + }) + } + catch { + return quickError(500, 'public_user_sync_failed', 'Failed to create user profile for merged SSO account') + } + + try { + await ensureOrgMembership(admin, requestId, originalUserId, mergeProvider.org_id) + } + catch { + return quickError(500, 'provision_failed', 'Failed to provision user to organization') } // Step 2: Transfer SSO identity from duplicate user (userId) → original user (originalUserId) @@ -354,9 +444,14 @@ app.post('/', async (c: Context) => { .select('id, org_id, domain, status') .eq('domain', userDomain) .eq('status', 'active') - .single() + .maybeSingle() + + if (providerError) { + cloudlogErr({ requestId, message: 'Failed to resolve SSO provider for domain', userId, domain: userDomain, error: providerError }) + return quickError(500, 'provider_lookup_failed', 'Failed to resolve SSO provider for your email domain') + } - if (providerError || !provider) { + if (!provider) { cloudlog({ requestId, message: 'No active SSO provider found for domain', userId, domain: userDomain }) return quickError(404, 'provider_not_found', 'No active SSO provider found for your email domain') } @@ -368,42 +463,19 @@ app.post('/', async (c: Context) => { return quickError(500, 'public_user_sync_failed', 'Failed to create user profile for SSO account') } - const { data: existingMembership, error: membershipCheckError } = await (admin as any) - .from('org_users') - .select('id') - .eq('user_id', userId) - .eq('org_id', provider.org_id) - .maybeSingle() - - if (membershipCheckError) { - cloudlogErr({ requestId, message: 'Failed to check existing org membership', userId, orgId: provider.org_id, error: membershipCheckError }) - return quickError(500, 'membership_check_failed', 'Failed to check organization membership') + let membershipResult: EnsureOrgMembershipResult + try { + membershipResult = await ensureOrgMembership(admin, requestId, userId, provider.org_id) + } + catch { + return quickError(500, 'provision_failed', 'Failed to provision user to organization') } - if (existingMembership) { + if (membershipResult.alreadyMember) { cloudlog({ requestId, message: 'User already belongs to org', userId, orgId: provider.org_id }) return c.json({ success: true, already_member: true }) } - const { error: insertError } = await (admin as any) - .from('org_users') - .insert({ - user_id: userId, - org_id: provider.org_id, - user_right: 'read', - }) - - if (insertError) { - // SQLSTATE 23505 = unique_violation — user was inserted between our check and insert (race condition) - const isDuplicate = insertError.code === '23505' || insertError.message?.toLowerCase().includes('duplicate') - if (isDuplicate) { - cloudlog({ requestId, message: 'User already member (concurrent insert)', userId, orgId: provider.org_id }) - return c.json({ success: true, already_member: true }) - } - cloudlogErr({ requestId, message: 'Failed to insert user into org_users', userId, orgId: provider.org_id, error: insertError }) - return quickError(500, 'provision_failed', 'Failed to provision user to organization') - } - cloudlog({ requestId, message: 'SSO user provisioned successfully', userId, orgId: provider.org_id, providerId: provider.id }) return c.json({ success: true }) } diff --git a/tests/auth-sso-provisioning.unit.test.ts b/tests/auth-sso-provisioning.unit.test.ts new file mode 100644 index 0000000000..cc7b1c764d --- /dev/null +++ b/tests/auth-sso-provisioning.unit.test.ts @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetClaims = vi.fn() +const mockGetSession = vi.fn() +const mockGetAuthenticatorAssuranceLevel = vi.fn() +const mockRpc = vi.fn() +const mockSignOut = vi.fn() +const mockSetUser = vi.fn() +const mockSendEvent = vi.fn().mockResolvedValue(undefined) +const mockHideLoader = vi.fn() +const mockCreateSignedImageUrl = vi.fn(async (value: string) => value) +const mockGetPlans = vi.fn(async () => []) +const mockIsPlatformAdmin = vi.fn(async () => false) + +const mainStore = { + auth: undefined as any, + user: undefined as any, + isAdmin: false, + plans: [] as any[], +} + +const organizationStore = { + organizations: [] as Array<{ gid: string, role: string }>, + hasOrganizations: false, + fetchOrganizations: vi.fn(async () => {}), + dedupFetchOrganizations: vi.fn(async () => {}), +} + +function createUsersQuery(userRecord: Record) { + return { + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + maybeSingle: vi.fn(async () => ({ + data: userRecord, + error: null, + })), + })), + })), + } +} + +vi.mock('~/services/loader', () => ({ + hideLoader: mockHideLoader, +})) + +vi.mock('~/services/posthog', () => ({ + setUser: mockSetUser, +})) + +vi.mock('~/services/storage', () => ({ + createSignedImageUrl: mockCreateSignedImageUrl, +})) + +vi.mock('~/services/tracking', () => ({ + sendEvent: mockSendEvent, +})) + +vi.mock('~/services/supabase', () => ({ + getLocalConfig: () => ({ supaHost: 'https://supabase.capgo.test' }), + getPlans: mockGetPlans, + isPlatformAdmin: mockIsPlatformAdmin, + useSupabase: () => ({ + auth: { + getClaims: mockGetClaims, + getSession: mockGetSession, + getAuthenticatorAssuranceLevel: mockGetAuthenticatorAssuranceLevel, + mfa: { + getAuthenticatorAssuranceLevel: mockGetAuthenticatorAssuranceLevel, + }, + signOut: mockSignOut, + }, + rpc: mockRpc, + from: vi.fn(() => createUsersQuery({ + id: 'user-123', + email: 'user@managed.test', + first_name: 'Managed', + last_name: 'User', + image_url: null, + })), + }), + defaultApiHost: 'https://api.capgo.test', +})) + +vi.mock('~/stores/main', () => ({ + useMainStore: () => mainStore, +})) + +vi.mock('~/stores/organization', () => ({ + useOrganizationStore: () => organizationStore, +})) + +async function getGuard() { + const router = { + beforeEach: vi.fn(), + } + + const { install } = await import('../src/modules/auth.ts') + install({ router } as any) + + const guard = router.beforeEach.mock.calls[0]?.[0] + if (!guard) + throw new Error('Auth guard was not registered') + + return guard +} + +describe('auth guard SSO provisioning', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + + mainStore.auth = undefined + mainStore.user = undefined + mainStore.isAdmin = false + mainStore.plans = [] + + organizationStore.organizations = [] + organizationStore.hasOrganizations = false + organizationStore.fetchOrganizations = vi.fn(async () => { + organizationStore.organizations = [{ gid: 'org-123', role: 'read' }] + organizationStore.hasOrganizations = true + }) + organizationStore.dedupFetchOrganizations = vi.fn(async () => {}) + + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true }), + }))) + + mockGetClaims.mockResolvedValue({ + data: { + claims: { + sub: 'user-123', + }, + }, + }) + mockGetSession.mockResolvedValue({ + data: { + session: { + access_token: 'token-123', + user: { + id: 'user-123', + email: 'user@managed.test', + email_confirmed_at: '2026-04-15T10:00:00.000Z', + app_metadata: { + provider: 'sso:provider-123', + providers: ['sso:provider-123'], + }, + }, + }, + }, + }) + mockGetAuthenticatorAssuranceLevel.mockResolvedValue({ + data: { + currentLevel: 'aal1', + nextLevel: 'aal1', + }, + error: null, + }) + mockRpc.mockResolvedValue({ + data: false, + error: null, + }) + mockSignOut.mockResolvedValue({ error: null }) + }) + + it('provisions an SSO session before redirecting to org onboarding and keeps the user on the target route', async () => { + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(fetch).toHaveBeenCalledWith('https://api.capgo.test/private/sso/provision-user', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer token-123', + }), + })) + expect(organizationStore.fetchOrganizations).toHaveBeenCalled() + expect(next).toHaveBeenCalledWith() + expect(next).not.toHaveBeenCalledWith('/onboarding/organization') + }) + + it('keeps redirecting non-SSO users without organizations to org onboarding', async () => { + mockGetSession.mockResolvedValue({ + data: { + session: { + access_token: 'token-123', + user: { + id: 'user-123', + email: 'user@managed.test', + email_confirmed_at: '2026-04-15T10:00:00.000Z', + app_metadata: { + provider: 'email', + providers: ['email'], + }, + }, + }, + }, + }) + + organizationStore.fetchOrganizations = vi.fn(async () => { + organizationStore.organizations = [] + organizationStore.hasOrganizations = false + }) + + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(fetch).not.toHaveBeenCalled() + expect(next).toHaveBeenCalledWith({ + path: '/onboarding/organization', + query: { + to: '/dashboard', + }, + }) + }) + + it('aborts navigation for managed SSO users when provisioning fails instead of redirecting to org onboarding', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + json: async () => ({ error: 'provider_lookup_failed' }), + }))) + + organizationStore.fetchOrganizations = vi.fn(async () => { + organizationStore.organizations = [] + organizationStore.hasOrganizations = false + }) + + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(fetch).toHaveBeenCalled() + expect(next).toHaveBeenCalledWith(false) + expect(next).not.toHaveBeenCalledWith({ + path: '/onboarding/organization', + query: { + to: '/dashboard', + }, + }) + }) + + it('aborts navigation when merged-session sign out fails instead of redirecting with a stale SSO session', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ success: true, merged: true }), + }))) + mockSignOut.mockResolvedValueOnce({ + error: new Error('sign out failed'), + }) + + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/dashboard', fullPath: '/dashboard', meta: { middleware: 'auth' }, query: {} }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(next).toHaveBeenCalledWith(false) + expect(next).not.toHaveBeenCalledWith('/login?message=sso_account_linked') + }) +}) diff --git a/tests/sso.test.ts b/tests/sso.test.ts index 18ad9f4722..f1c1535e8d 100644 --- a/tests/sso.test.ts +++ b/tests/sso.test.ts @@ -574,7 +574,10 @@ describe('[POST] /private/sso/provision-user', () => { }) expect(response.status).toBe(200) - const responseBody = await response.json() + const responseBody = await response.json() as { + success: boolean + already_member?: boolean + } expect(responseBody).toMatchObject({ success: true }) const { data: publicUser, error: publicUserError } = await getSupabaseClient() @@ -609,6 +612,130 @@ describe('[POST] /private/sso/provision-user', () => { } }) + it('promotes invite-only org memberships during SSO provisioning instead of treating them as completed membership', async () => { + const managedOrgId = randomUUID() + const managedCustomerId = `cus_sso_invite_promotion_${randomUUID()}` + const providerId = randomUUID() + const domain = `${randomUUID()}.sso.test` + const email = `invite-only-user@${domain}` + const password = 'testtest' + const identityProvider = `sso:${providerId}` + const identityProviderId = `nameid-${randomUUID()}` + const pool = new Pool({ connectionString: POSTGRES_URL }) + + const { data: createdUser, error: createUserError } = await getSupabaseClient().auth.admin.createUser({ + email, + password, + email_confirm: true, + app_metadata: { + provider: identityProvider, + }, + user_metadata: { + first_name: 'Invite', + last_name: 'Promotion', + }, + }) + if (createUserError || !createdUser.user) { + await pool.end() + throw createUserError ?? new Error('Failed to create SSO auth user for invite promotion test') + } + + try { + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: managedCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_sso_invite_promotion_${randomUUID()}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + const { error: orgError } = await getSupabaseClient().from('orgs').insert({ + id: managedOrgId, + name: `SSO Invite Promotion Org ${managedOrgId}`, + management_email: `sso-invite-promotion-${managedOrgId}@capgo.app`, + created_by: USER_ID, + customer_id: managedCustomerId, + sso_enabled: true, + }) + if (orgError) + throw orgError + + const { error: providerError } = await (getSupabaseClient().from as any)('sso_providers').insert({ + id: providerId, + org_id: managedOrgId, + domain, + provider_id: randomUUID(), + status: 'active', + enforce_sso: false, + dns_verification_token: `dns-${randomUUID()}`, + }) + if (providerError) + throw providerError + + const { error: publicUserError } = await getSupabaseClient().from('users').insert({ + id: createdUser.user.id, + email, + first_name: 'Invite', + last_name: 'Promotion', + country: null, + enable_notifications: true, + opt_for_newsletters: true, + }) + if (publicUserError) + throw publicUserError + + const { error: inviteMembershipError } = await getSupabaseClient().from('org_users').insert({ + org_id: managedOrgId, + user_id: createdUser.user.id, + user_right: 'invite_read' as const, + }) + if (inviteMembershipError) + throw inviteMembershipError + + await pool.query( + 'update auth.identities set provider = $1, provider_id = $2, identity_data = jsonb_build_object($$sub$$, $2::text, $$email$$, $3::text, $$email_verified$$, true) where user_id = $4', + [identityProvider, identityProviderId, email, createdUser.user.id], + ) + + const ssoAuthHeaders = await getAuthHeadersForCredentials(email, password) + const response = await fetchWithRetry(getEndpointUrl('/private/sso/provision-user'), { + method: 'POST', + headers: ssoAuthHeaders, + body: JSON.stringify({}), + }) + + const responseBody = await response.json() as { + success: boolean + already_member?: boolean + } + expect(response.status).toBe(200) + expect(responseBody).toMatchObject({ success: true }) + expect(responseBody.already_member).toBeUndefined() + + const { data: membership, error: membershipError } = await getSupabaseClient() + .from('org_users') + .select('user_right') + .eq('org_id', managedOrgId) + .eq('user_id', createdUser.user.id) + .single() + + expect(membershipError).toBeNull() + expect(membership?.user_right).toBe('read') + } + finally { + await Promise.allSettled([ + getSupabaseClient().auth.admin.deleteUser(createdUser.user.id), + (getSupabaseClient().from as any)('sso_providers').delete().eq('id', providerId), + getSupabaseClient().from('orgs').delete().eq('id', managedOrgId), + getSupabaseClient().from('stripe_info').delete().eq('customer_id', managedCustomerId), + ]) + await pool.end() + } + }) + it('merges an existing password account when a new SSO auth user arrives with the same email', async () => { const managedOrgId = randomUUID() const managedCustomerId = `cus_sso_merge_${randomUUID()}` @@ -737,7 +864,10 @@ describe('[POST] /private/sso/provision-user', () => { body: JSON.stringify({}), }) - const responseBody = await response.json() + const responseBody = await response.json() as { + success: boolean + merged?: boolean + } expect(response.status).toBe(200) expect(responseBody).toMatchObject({ success: true, merged: true }) @@ -753,6 +883,20 @@ describe('[POST] /private/sso/provision-user', () => { const { data: duplicateAuthLookup } = await getSupabaseClient().auth.admin.getUserById(duplicateUser.user.id) expect(duplicateAuthLookup.user).toBeNull() + const { data: mergedMembership, error: mergedMembershipError } = await getSupabaseClient() + .from('org_users') + .select('org_id, user_id, user_right') + .eq('org_id', managedOrgId) + .eq('user_id', originalUser.user.id) + .maybeSingle() + + expect(mergedMembershipError).toBeNull() + expect(mergedMembership).toMatchObject({ + org_id: managedOrgId, + user_id: originalUser.user.id, + user_right: 'read', + }) + const identitiesAfterMerge = await pool.query( 'select provider, provider_id, user_id, email from auth.identities where user_id = $1 order by provider, provider_id', [originalUser.user.id], @@ -883,7 +1027,9 @@ describe('[POST] /private/sso/provision-user', () => { body: JSON.stringify({}), }) - const responseBody = await response.json() + const responseBody = await response.json() as { + success: boolean + } expect(response.status).toBe(200) expect(responseBody).toMatchObject({ success: true })