Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 8 additions & 57 deletions src/composables/useSSOProvisioning.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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<string, unknown>
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) {
Expand Down
52 changes: 51 additions & 1 deletion src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -82,6 +83,33 @@ async function updateUser(
}
}

async function maybeProvisionSsoMembership(
supabase: SupabaseClient,
session: Awaited<ReturnType<SupabaseClient['auth']['getSession']>>['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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (result.error) {
console.error('Failed to provision SSO membership during auth guard:', result.error)
return 'abort_navigation'
}

return 'continue'
Comment thread
riderx marked this conversation as resolved.
Comment thread
riderx marked this conversation as resolved.
}

async function guard(
next: NavigationGuardNext,
to: RouteLocationNormalized,
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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')
}
Expand Down
68 changes: 68 additions & 0 deletions src/services/ssoProvisioning.ts
Original file line number Diff line number Diff line change
@@ -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<User, 'app_metadata'> | 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<SsoProvisioningResult> {
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<string, unknown>
const errorMessage = typeof errorData.error === 'string'
? errorData.error
: typeof errorData.message === 'string'
? errorData.message
: `Provisioning failed (${response.status})`

Check warning on line 40 in src/services/ssoProvisioning.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ2RssJdnlMn1047UOds&open=AZ2RssJdnlMn1047UOds&pullRequest=1913

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',
}
}
}
Loading
Loading