diff --git a/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json b/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json index 7137aa6c8..4324ffb97 100644 --- a/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json +++ b/builtin-configs/apps/issue-desk/workflows/issue-desk/desk-process.json @@ -21,10 +21,7 @@ "integrations": [ { "name": "github", - "operations": [ - "get_issue", - "create_pull_request" - ] + "operations": ["get_issue", "create_pull_request"] } ] }, diff --git a/services/platform/app/routes/_auth/log-in.tsx b/services/platform/app/routes/_auth/log-in.tsx index f30bf8b80..4f1606898 100644 --- a/services/platform/app/routes/_auth/log-in.tsx +++ b/services/platform/app/routes/_auth/log-in.tsx @@ -255,19 +255,69 @@ export function LogInPage() { } }; - const handleSsoLogin = useCallback(() => { + const handleSsoLogin = useCallback(async () => { const siteUrl = getEnv('SITE_URL'); const basePath = getEnv('BASE_PATH'); const base = `${siteUrl}${basePath}/http_api/api/sso`; + + // Route to the org whose connection matches the entered email. Each SSO + // connection is scoped per organization, so on a multi-org deployment we + // must resolve WHICH org before starting the flow — otherwise the server + // falls back to the first enabled connection and second-org users land at + // the wrong IdP (#2082). Discovery also tells us the protocol, which we use + // for the SAML-vs-OIDC branch instead of the deployment-global status + // (which likewise reports only the first connection). + const email = form.getValues('email')?.trim(); + let organizationId: string | undefined; + let protocol: string | undefined; + if (email) { + try { + const res = await fetch(`${base}/discover`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (res.ok) { + const data: { + ssoEnabled?: boolean; + organizationId?: string; + protocol?: string; + } = await res.json(); + if (data.ssoEnabled) { + organizationId = data.organizationId; + protocol = data.protocol; + } + } + } catch (error) { + // Network/parse failure: fall back to the deployment-global status + // below rather than blocking sign-in. + console.warn( + '[SSO] Discovery failed, using default connection:', + error, + ); + } + } + + // Fall back to the global status when discovery didn't resolve a connection + // (empty email, single-org deployment, or discovery failure). + const resolvedProtocol = protocol ?? ssoConfig?.providerType; + // SAML uses SP-initiated redirect (AuthnRequest); OIDC/OAuth2 use the // authorization-code flow via /authorize. - if (ssoConfig?.providerType === 'saml') { - window.location.href = `${base}/saml/login`; + if (resolvedProtocol === 'saml') { + const samlUrl = new URL(`${base}/saml/login`); + if (organizationId) samlUrl.searchParams.set('org', organizationId); + window.location.href = samlUrl.toString(); return; } - const callbackUri = `${base}/callback`; - window.location.href = `${base}/authorize?redirect_uri=${encodeURIComponent(callbackUri)}`; - }, [ssoConfig?.providerType]); + const authorizeUrl = new URL(`${base}/authorize`); + authorizeUrl.searchParams.set('redirect_uri', `${base}/callback`); + if (email) authorizeUrl.searchParams.set('email', email); + if (organizationId) { + authorizeUrl.searchParams.set('organizationId', organizationId); + } + window.location.href = authorizeUrl.toString(); + }, [ssoConfig?.providerType, form]); // Passkey / WebAuthn sign-in (#1508). Drives the browser's get-credential // ceremony; on success the session is live, so refresh the cache and route diff --git a/services/platform/convex/enterprise_sso/internal_queries.test.ts b/services/platform/convex/enterprise_sso/internal_queries.test.ts new file mode 100644 index 000000000..f674d02d6 --- /dev/null +++ b/services/platform/convex/enterprise_sso/internal_queries.test.ts @@ -0,0 +1,113 @@ +import { convexTest } from 'convex-test'; +import { defineSchema } from 'convex/server'; +import { describe, expect, it } from 'vitest'; + +import { + SSO_CONFIG_DOMAIN, + SSO_CONNECTION_KEY, +} from '../../lib/shared/schemas/enterprise_sso'; +import { internal } from '../_generated/api'; +import { configCacheTable } from '../lib/config_cache/schema'; +import { buildModules } from '../migrations/framework/test_helpers'; + +// Minimal schema: the internal SSO sign-in reads only the `configCache` mirror. +const schema = defineSchema({ configCache: configCacheTable }); +const modules = buildModules( + import.meta.glob('../../**/*.*s'), + 'enterprise_sso', +); + +type ConnArgs = { + organizationId: string; + domain?: string; + enabled?: boolean; +}; + +/** Seed an enabled OIDC connection for an org into the configCache mirror. */ +function seedConnection({ organizationId, domain, enabled = true }: ConnArgs) { + return { + organizationId, + domain: SSO_CONFIG_DOMAIN, + key: SSO_CONNECTION_KEY, + enabled, + syncedAt: 0, + config: { + enabled, + protocol: 'oidc', + displayName: `SSO ${organizationId}`, + ...(domain ? { domain } : {}), + oidc: { + providerId: 'generic-oidc', + issuer: `https://idp.${organizationId}.example.com`, + scopes: ['openid', 'email'], + }, + provisioning: { + autoProvisionRole: false, + defaultRole: 'member', + roleMappingRules: [], + autoProvisionTeam: false, + excludeGroups: [], + }, + }, + }; +} + +describe('enterprise_sso internal_queries — multi-org routing (#2082)', () => { + it('resolveSignInConfig scopes to the requested org, not the first enabled', async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await ctx.db.insert( + 'configCache', + seedConnection({ organizationId: 'orgA' }), + ); + await ctx.db.insert( + 'configCache', + seedConnection({ organizationId: 'orgB' }), + ); + }); + + const orgB = await t.query( + internal.enterprise_sso.internal_queries.resolveSignInConfig, + { organizationId: 'orgB' }, + ); + expect(orgB?.organizationId).toBe('orgB'); + expect(orgB?.issuer).toBe('https://idp.orgB.example.com'); + }); + + it('discoverByEmail routes by email domain across orgs', async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await ctx.db.insert( + 'configCache', + seedConnection({ organizationId: 'orgA', domain: 'a-corp.com' }), + ); + await ctx.db.insert( + 'configCache', + seedConnection({ organizationId: 'orgB', domain: 'b-corp.com' }), + ); + }); + + const match = await t.query( + internal.enterprise_sso.internal_queries.discoverByEmail, + { email: 'user@b-corp.com' }, + ); + expect(match?.organizationId).toBe('orgB'); + expect(match?.protocol).toBe('oidc'); + }); + + it('discoverByEmail falls back to the first enabled connection on no domain match', async () => { + const t = convexTest(schema, modules); + await t.run(async (ctx) => { + await ctx.db.insert( + 'configCache', + seedConnection({ organizationId: 'orgA', domain: 'a-corp.com' }), + ); + }); + + const match = await t.query( + internal.enterprise_sso.internal_queries.discoverByEmail, + { email: 'user@unknown.com' }, + ); + expect(match?.organizationId).toBe('orgA'); + }); +}); diff --git a/services/platform/convex/enterprise_sso/login/authorize_handler.ts b/services/platform/convex/enterprise_sso/login/authorize_handler.ts index 9072f5336..9b720ffb3 100644 --- a/services/platform/convex/enterprise_sso/login/authorize_handler.ts +++ b/services/platform/convex/enterprise_sso/login/authorize_handler.ts @@ -34,6 +34,10 @@ export async function ssoAuthorizeHandler( try { const url = new URL(req.url); const email = url.searchParams.get('email'); + // Which org's connection to use. The login screen resolves this from the + // user's email via /api/sso/discover; without it we'd fall back to the + // first enabled connection regardless of email (multi-org collision, #2082). + const organizationId = url.searchParams.get('organizationId') || undefined; const promptParam = url.searchParams.get('prompt'); const seamlessParam = url.searchParams.get('seamless'); const claimsParam = url.searchParams.get('claims'); @@ -50,7 +54,7 @@ export async function ssoAuthorizeHandler( const config = await ctx.runQuery( internal.enterprise_sso.internal_queries.resolveSignInConfig, - {}, + organizationId ? { organizationId } : {}, ); if (!config) { return new Response('No SSO configuration found', { status: 404 }); @@ -89,6 +93,10 @@ export async function ssoAuthorizeHandler( redirectUri, timestamp: Date.now(), seamless: prompt === 'none', + // Carry the resolved org through the signed state so the callback resolves + // the SAME connection — otherwise it would re-resolve to the first enabled + // one and could exchange the code against the wrong org's IdP (#2082). + organizationId: config.organizationId, ...(encryptedPkceVerifier ? { pkce: encryptedPkceVerifier } : {}), }); const base64Payload = btoa(statePayload) diff --git a/services/platform/convex/enterprise_sso/login/callback_handler.ts b/services/platform/convex/enterprise_sso/login/callback_handler.ts index d5d1c9c9a..95ff67f6b 100644 --- a/services/platform/convex/enterprise_sso/login/callback_handler.ts +++ b/services/platform/convex/enterprise_sso/login/callback_handler.ts @@ -84,7 +84,13 @@ export async function ssoCallbackHandler( const authorizeUrl = buildAuthorizeRedirectUrl( url.origin, stateData.redirectUri, - { prompt: 'login' }, + { + prompt: 'login', + // Keep the retry pinned to the same org (#2082). + ...(stateData.organizationId + ? { organizationId: stateData.organizationId } + : {}), + }, ); return new Response(null, { status: 302, @@ -101,6 +107,10 @@ export async function ssoCallbackHandler( extractClaimsChallenge(errorDescription); const params: Record = { prompt: 'login' }; if (claimsChallenge) params['claims'] = claimsChallenge; + // Keep the step-up retry pinned to the same org (#2082). + if (stateData.organizationId) { + params['organizationId'] = stateData.organizationId; + } const authorizeUrl = buildAuthorizeRedirectUrl( url.origin, stateData.redirectUri, @@ -151,7 +161,12 @@ export async function ssoCallbackHandler( return redirectWithError(url.origin, 'Invalid state signature'); } - let state: { redirectUri: string; timestamp: number; pkce?: string }; + let state: { + redirectUri: string; + timestamp: number; + pkce?: string; + organizationId?: string; + }; try { const base64 = verifiedPayload.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); @@ -166,9 +181,12 @@ export async function ssoCallbackHandler( const frontendOrigin = new URL(state.redirectUri).origin; + // Resolve the SAME org's connection the authorize step used (carried in the + // signed state), not the first enabled one, so the code is exchanged against + // the correct org's IdP in multi-org deployments (#2082). const config = await ctx.runQuery( internal.enterprise_sso.internal_queries.resolveSignInConfig, - {}, + state.organizationId ? { organizationId: state.organizationId } : {}, ); if (!config) { return redirectWithError(frontendOrigin, 'SSO configuration not found');