Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,7 @@
"integrations": [
{
"name": "github",
"operations": [
"get_issue",
"create_pull_request"
]
"operations": ["get_issue", "create_pull_request"]
}
]
},
Expand Down
62 changes: 56 additions & 6 deletions services/platform/app/routes/_auth/log-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions services/platform/convex/enterprise_sso/internal_queries.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 21 additions & 3 deletions services/platform/convex/enterprise_sso/login/callback_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -101,6 +107,10 @@ export async function ssoCallbackHandler(
extractClaimsChallenge(errorDescription);
const params: Record<string, string> = { 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,
Expand Down Expand Up @@ -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);
Expand All @@ -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');
Expand Down