From c3d90bc2ea0719218f2a701654aa26c5fd720a8f Mon Sep 17 00:00:00 2001 From: Dylan Staley <88163+dstaley@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:24:29 -0500 Subject: [PATCH] feat(e2e): sso/scim tests with keycloak --- .changeset/quick-test-domain-verification.md | 5 + .../self-serve-sso-organization-setting.md | 5 + .changeset/sso-testing-page-objects.md | 5 + .changeset/steady-sso-wizard.md | 5 + .github/workflows/ci.yml | 11 + integration/configs/with-self-serve-sso.js | 20 ++ integration/presets/envs.ts | 5 + integration/presets/longRunningApps.ts | 3 + integration/scripts/runSso.mjs | 197 ++++++++++++++ integration/sso/fixtures.ts | 147 +++++++++++ integration/sso/keycloak.ts | 121 +++++++++ integration/sso/scim.ts | 34 +++ integration/templates/react-vite/src/main.tsx | 2 +- integration/tests/components.test.ts | 2 +- integration/tests/sso/access-control.test.ts | 72 ++++++ .../tests/sso/configuration-tests.test.ts | 99 +++++++ .../tests/sso/connection-management.test.ts | 89 +++++++ .../tests/sso/directory-tokens.test.ts | 72 ++++++ .../tests/sso/domain-verification.test.ts | 57 +++++ integration/tests/sso/saml-sign-in.test.ts | 57 +++++ integration/tests/sso/setup-wizards.test.ts | 166 ++++++++++++ integration/tests/sso/sso-bypass.test.ts | 241 ++++++++++++++++++ package.json | 2 + .../src/api/endpoints/OrganizationApi.ts | 1 + .../react/hooks/useOrganizationDomains.tsx | 11 +- .../useOrganizationEnterpriseConnections.tsx | 11 +- .../unstable/page-objects/configureSSO.ts | 66 +++++ .../playwright/unstable/page-objects/index.ts | 2 + .../page-objects/organizationProfile.ts | 3 + turbo.json | 7 + 30 files changed, 1512 insertions(+), 6 deletions(-) create mode 100644 .changeset/quick-test-domain-verification.md create mode 100644 .changeset/self-serve-sso-organization-setting.md create mode 100644 .changeset/sso-testing-page-objects.md create mode 100644 .changeset/steady-sso-wizard.md create mode 100644 integration/configs/with-self-serve-sso.js create mode 100644 integration/scripts/runSso.mjs create mode 100644 integration/sso/fixtures.ts create mode 100644 integration/sso/keycloak.ts create mode 100644 integration/sso/scim.ts create mode 100644 integration/tests/sso/access-control.test.ts create mode 100644 integration/tests/sso/configuration-tests.test.ts create mode 100644 integration/tests/sso/connection-management.test.ts create mode 100644 integration/tests/sso/directory-tokens.test.ts create mode 100644 integration/tests/sso/domain-verification.test.ts create mode 100644 integration/tests/sso/saml-sign-in.test.ts create mode 100644 integration/tests/sso/setup-wizards.test.ts create mode 100644 integration/tests/sso/sso-bypass.test.ts create mode 100644 packages/testing/src/playwright/unstable/page-objects/configureSSO.ts diff --git a/.changeset/quick-test-domain-verification.md b/.changeset/quick-test-domain-verification.md new file mode 100644 index 00000000000..f6a9ef084f7 --- /dev/null +++ b/.changeset/quick-test-domain-verification.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Modify the polling interval for SSO domain verification on development instances. \ No newline at end of file diff --git a/.changeset/self-serve-sso-organization-setting.md b/.changeset/self-serve-sso-organization-setting.md new file mode 100644 index 00000000000..a85e588ef47 --- /dev/null +++ b/.changeset/self-serve-sso-organization-setting.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add `selfServeSsoEnabled` to the type parameters for `updateOrganization()` \ No newline at end of file diff --git a/.changeset/sso-testing-page-objects.md b/.changeset/sso-testing-page-objects.md new file mode 100644 index 00000000000..078030c1627 --- /dev/null +++ b/.changeset/sso-testing-page-objects.md @@ -0,0 +1,5 @@ +--- +'@clerk/testing': minor +--- + +Add a Playwright page object for the ConfigureSSO flows. \ No newline at end of file diff --git a/.changeset/steady-sso-wizard.md b/.changeset/steady-sso-wizard.md new file mode 100644 index 00000000000..41b0eb708b0 --- /dev/null +++ b/.changeset/steady-sso-wizard.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Fix an issue where the self-serve SSO setup wizard could return to the domain step after creating a connection. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38fcec32813..c41b0981014 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -422,6 +422,8 @@ jobs: - test-name: 'mosaic' test-project: 'chrome' next-version: '16' + - test-name: 'sso' + test-project: 'chrome' steps: - name: Checkout Repo @@ -596,6 +598,15 @@ jobs: path: integration/test-results retention-days: 1 + - name: Delete private SSO resources + if: ${{ always() && matrix.test-name == 'sso' && steps.integration-tests.outcome != 'skipped' }} + timeout-minutes: 3 + run: pnpm test:integration:sso:cleanup + env: + CLERK_PLATFORM_API_KEY: ${{ secrets.CLERK_PLATFORM_API_KEY }} + INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} + NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/integration/certs/rootCA.pem + - name: Delete integration-test users if: ${{ always() && steps.integration-tests.outcome != 'skipped' }} timeout-minutes: 10 diff --git a/integration/configs/with-self-serve-sso.js b/integration/configs/with-self-serve-sso.js new file mode 100644 index 00000000000..53193e9482e --- /dev/null +++ b/integration/configs/with-self-serve-sso.js @@ -0,0 +1,20 @@ +import { defineConfig } from '../presets/platformApplication.js'; + +export default defineConfig({ + config: { + auth_email: { sign_in_strategies: ['email_code'] }, + auth_multi_factor: { authenticator_app: { enabled: true }, backup_code: { enabled: true } }, + user_model: { first_name: { enabled: true }, last_name: { enabled: true } }, + organization_settings: { + enabled: true, + domains_enabled: true, + domains_enrollment_modes: ['manual_invitation'], + force_organization_selection: false, + max_allowed_memberships: 10, + }, + auth_attack_protection: { bot_protection: { captcha_enabled: false } }, + }, + setup: async ({ clerkClient }) => { + await clerkClient.instance.update({ testMode: true }); + }, +}); diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 88be57a72e6..2f7a6dfdc76 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -147,6 +147,10 @@ const base = environmentConfig() .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') .setEnvVariable('public', 'CLERK_UI_URL', constants.E2E_APP_CLERK_UI || 'http://localhost:18212/ui.browser.js'); +const withSelfServeSso = process.env.E2E_KEYCLOAK_URL + ? await withInstanceKeys('with-self-serve-sso', base.clone().setId('withSelfServeSso')) + : base.clone().setId('withSelfServeSso'); + const withKeyless = base .clone() // Creates keyless applications in our staging database. @@ -323,6 +327,7 @@ const withPasskeys = await withInstanceKeys('with-passkeys', base.clone().setId( export const envs = { base, + withSelfServeSso, sessionsProd1, withAPIKeys, withAPCore3ClerkLatest, diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index d9cb2b43a1e..4e61afcbdf2 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -71,6 +71,9 @@ export const createLongRunningApps = () => { { id: 'react.vite.withEmailCodes_persist_client', config: react.vite, env: envs.withEmailCodes_destroy_client }, { id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks }, { id: 'react.vite.withLegalConsent', config: react.vite, env: envs.withLegalConsent }, + ...(process.env.E2E_KEYCLOAK_URL + ? [{ id: 'react.vite.withSelfServeSso', config: react.vite, env: envs.withSelfServeSso }] as const + : []), { id: 'vue.vite', config: vue.vite, env: envs.withCustomRoles }, /** diff --git a/integration/scripts/runSso.mjs b/integration/scripts/runSso.mjs new file mode 100644 index 00000000000..99dec2fa53b --- /dev/null +++ b/integration/scripts/runSso.mjs @@ -0,0 +1,197 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; + +import { config } from 'dotenv'; +import execa from 'execa'; + +import { getE2ERunToken } from '../testUtils/e2eRun.ts'; + +config({ path: resolve('integration/.env.local') }); + +const runKey = process.env.INTEGRATION_TEST_RUN_KEY || randomUUID(); +const runId = getE2ERunToken(runKey); +const stateDir = resolve(tmpdir(), '.temp_integration', 'sso', runId); +const runtimePath = resolve(stateDir, 'runtime.json'); +const docker = args => execa('docker', args, { timeout: 30_000 }); + +async function cleanup() { + try { + await readdir(stateDir); + } catch (error) { + if (error.code === 'ENOENT') { + return; + } + throw error; + } + + const failures = []; + try { + await execa('pnpm', ['test:integration:cleanup', '--output', resolve(stateDir, 'cleanup-results')], { + stdio: 'inherit', + timeout: 300_000, + env: { INTEGRATION_TEST_RUN_KEY: runKey, E2E_CLEANUP_STALE_APPLICATIONS: '0' }, + }); + } catch (error) { + failures.push(error); + } + + try { + const runtime = await readFile(runtimePath, 'utf8').catch(error => { + if (error.code === 'ENOENT') { + return null; + } + throw error; + }); + const containerId = runtime ? JSON.parse(runtime).containerId : `clerk-sso-${runId}`; + const inspected = await docker(['inspect', containerId]).catch(error => { + if (error.stderr?.includes('No such')) { + return null; + } + throw error; + }); + if (inspected) { + const [container] = JSON.parse(inspected.stdout); + if (container.Config.Labels?.['clerk.e2e.sso.run'] !== runId) { + throw new Error('The container does not belong to this SSO test run.'); + } + try { + const logs = await docker(['logs', containerId]); + await mkdir(resolve('integration/test-results'), { recursive: true }); + await writeFile(resolve('integration/test-results', `keycloak-${runId}.log`), logs.stdout + logs.stderr); + } catch (error) { + failures.push(error); + } + for (const args of [ + ['stop', '--time', '10', containerId], + ['rm', '--force', containerId], + ]) { + try { + await docker(args); + } catch (error) { + failures.push(error); + } + } + } + } catch (error) { + if (error.code !== 'ENOENT') { + failures.push(error); + } + } + + if (failures.length) { + throw new AggregateError(failures, `SSO cleanup failed. Retry with INTEGRATION_TEST_RUN_KEY=${runKey}.`); + } + await rm(stateDir, { recursive: true, force: true }); + console.log('SSO test resources removed.'); +} + +async function run() { + if (!process.env.CLERK_PLATFORM_API_KEY) { + throw new Error('Set CLERK_PLATFORM_API_KEY in integration/.env.local or the environment.'); + } + await docker(['info', '--format', '{{.ServerVersion}}']); + await mkdir(resolve(stateDir, '..'), { recursive: true, mode: 0o700 }); + await mkdir(stateDir, { mode: 0o700 }); + const adminPassword = randomUUID(); + const controller = new AbortController(); + let child; + const interrupt = signal => { + controller.abort(); + child?.kill(signal); + process.exitCode = signal === 'SIGINT' ? 130 : 143; + }; + const onInterrupt = () => interrupt('SIGINT'); + const onTerminate = () => interrupt('SIGTERM'); + process.on('SIGINT', onInterrupt); + process.on('SIGTERM', onTerminate); + + try { + const created = await execa( + 'docker', + [ + 'create', + '--name', + `clerk-sso-${runId}`, + '--label', + `clerk.e2e.sso.run=${runId}`, + '--publish', + '127.0.0.1::8080', + '--publish', + '127.0.0.1::9000', + '--env', + 'KC_BOOTSTRAP_ADMIN_USERNAME=e2e-admin', + '--env', + 'KC_BOOTSTRAP_ADMIN_PASSWORD', + 'quay.io/keycloak/keycloak:26.8.0', + 'start-dev', + '--db=dev-mem', + '--hostname-strict=false', + '--health-enabled=true', + ], + { env: { KC_BOOTSTRAP_ADMIN_PASSWORD: adminPassword }, signal: controller.signal }, + ); + const containerId = created.stdout.trim(); + await writeFile(runtimePath, JSON.stringify({ containerId }), { mode: 0o600 }); + await docker(['start', containerId]); + const [container] = JSON.parse((await docker(['inspect', containerId])).stdout); + const port = name => { + const binding = container.NetworkSettings.Ports[`${name}/tcp`]?.[0]; + if (binding?.HostIp !== '127.0.0.1') { + throw new Error('Keycloak must be bound to the loopback interface.'); + } + return binding.HostPort; + }; + const baseUrl = `http://127.0.0.1:${port(8080)}`; + const healthUrl = `http://127.0.0.1:${port(9000)}/health/ready`; + const deadline = Date.now() + 120_000; + while (true) { + controller.signal.throwIfAborted(); + const ready = await fetch(healthUrl, { + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(2000)]), + }) + .then(response => response.ok) + .catch(() => false); + if (ready) { + break; + } + if (Date.now() >= deadline) { + throw new Error('Keycloak did not become ready within 120 seconds.'); + } + await setTimeout(250, undefined, { signal: controller.signal }); + } + console.log(`Private Keycloak ready at ${baseUrl}.`); + child = execa('pnpm', ['test:integration:base', '--grep', '@sso', '--project=chrome', ...process.argv.slice(2)], { + stdio: 'inherit', + reject: false, + env: { + INTEGRATION_TEST_RUN_KEY: runKey, + E2E_APP_ID: 'react.vite.withSelfServeSso', + E2E_KEYCLOAK_URL: baseUrl, + E2E_KEYCLOAK_ADMIN_PASSWORD: adminPassword, + }, + }); + const result = await child; + process.exitCode ||= result.exitCode || (result.signal ? 1 : 0); + } finally { + await cleanup(); + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onTerminate); + } +} + +try { + if (process.argv[2] === '--cleanup') { + if (!process.env.INTEGRATION_TEST_RUN_KEY) { + throw new Error('Set INTEGRATION_TEST_RUN_KEY to the SSO test run to clean up.'); + } + await cleanup(); + } else { + await run(); + } +} catch (error) { + console.error(error); + process.exitCode ||= 1; +} diff --git a/integration/sso/fixtures.ts b/integration/sso/fixtures.ts new file mode 100644 index 00000000000..0fcf7eebfbd --- /dev/null +++ b/integration/sso/fixtures.ts @@ -0,0 +1,147 @@ +import type { EnterpriseConnection, Organization } from '@clerk/backend'; +import type { OrganizationResource } from '@clerk/shared/types'; +import { clerkSetup, setupClerkTestingToken } from '@clerk/testing/playwright'; +import { type BrowserContext, expect, type Page, test as base } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { createTestUtils, testAgainstRunningApps } from '../testUtils'; +import { Keycloak } from './keycloak'; + +async function prepareContext(context: BrowserContext) { + await setupClerkTestingToken({ context }); + await context.route('**/v1/saml/acs/**', route => route.continue()); + await context.route('**/v1/enterprise_connections/*/init_test_run?**', route => route.continue()); +} + +export const test = base.extend<{ + app: Application; + keycloak: Keycloak; + organization: Organization; + createConnection: (page: Page) => Promise; + directoryIds: Set; +}>({ + app: [undefined as unknown as Application, { option: true }], + keycloak: async ({ browserName: _browserName }, provide) => { + const url = process.env.E2E_KEYCLOAK_URL; + const password = process.env.E2E_KEYCLOAK_ADMIN_PASSWORD; + if (!url || !password) { + throw new Error('Run these tests with pnpm test:integration:sso.'); + } + const keycloak = new Keycloak(url, password); + try { + await keycloak.createRealm(keycloak.realm); + await keycloak.createUser(keycloak.realm, keycloak.email, keycloak.password); + await provide(keycloak); + } finally { + await keycloak.deleteRealm(keycloak.realm); + } + }, + context: async ({ context, app }, provide) => { + await clerkSetup({ + publishableKey: app.env.publicVariables.get('CLERK_PUBLISHABLE_KEY'), + secretKey: app.env.privateVariables.get('CLERK_SECRET_KEY'), + dotenv: false, + }); + await prepareContext(context); + await provide(context); + }, + organization: async ({ app, keycloak }, provide) => { + const u = createTestUtils({ app }); + const fakeAdmin = { + ...u.services.users.createFakeUser(test, { withPassword: false }), + email: `admin+clerk_test@admin-${keycloak.realm}.clerk.test`, + }; + const admin = await u.services.users.createBapiUser(fakeAdmin); + let organization: Organization | undefined; + try { + organization = await u.services.clerk.organizations.createOrganization({ + name: 'Keycloak Test Organization', + createdBy: admin.id, + }); + await u.services.clerk.organizations.updateOrganization(organization.id, { selfServeSsoEnabled: true }); + await provide(organization); + } finally { + try { + if (organization) { + await u.services.clerk.organizations.deleteOrganization(organization.id); + } + } finally { + const users = await u.services.clerk.users.getUserList({ query: keycloak.realm, limit: 100 }); + await Promise.all(users.data.map(user => u.services.clerk.users.deleteUser(user.id))); + } + } + }, + createConnection: async ({ app, organization, keycloak }, provide) => { + const u = createTestUtils({ app }); + let connection: EnterpriseConnection | undefined; + try { + await provide(async page => { + await page.evaluate(async name => { + const organization = window.Clerk.organization as OrganizationResource; + const domain = await organization.createDomain(name, { enrollmentMode: 'enterprise_sso' }); + await organization.prepareOwnershipVerification([domain.id]); + const result = await organization.attemptOwnershipVerification([domain.id]); + if (result.errors.length || result.data[0]?.ownershipVerification?.status !== 'verified') { + throw new Error('Test domain ownership verification failed.'); + } + }, keycloak.domain); + connection = await u.services.clerk.enterpriseConnections.createEnterpriseConnection({ + provider: 'saml_custom', + name: `Private Keycloak ${keycloak.domain}`, + organizationId: organization.id, + domains: [keycloak.domain], + saml: { + idpMetadata: await keycloak.metadata(keycloak.realm), + }, + }); + await keycloak.createSamlClient( + keycloak.realm, + connection.samlConnection?.spEntityId, + connection.samlConnection?.acsUrl, + ); + await u.services.clerk.enterpriseConnections.updateEnterpriseConnection(connection.id, { active: true }); + return connection; + }); + } finally { + if (connection) { + await u.services.clerk.enterpriseConnections.deleteEnterpriseConnection(connection.id).catch(error => { + if (error.status !== 404) { + throw error; + } + }); + } + } + }, + directoryIds: async ({ app, request, organization: _organization }, provide) => { + const ids = new Set(); + try { + await provide(ids); + } finally { + for (const id of ids) { + const response = await request.delete( + `${app.env.privateVariables.get('CLERK_API_URL') || 'https://api.clerk.com'}/v1/directories/${id}`, + { + headers: { Authorization: `Bearer ${app.env.privateVariables.get('CLERK_SECRET_KEY')}` }, + }, + ); + expect(response.ok() || response.status() === 404, `Directory cleanup failed (${response.status()}).`).toBe( + true, + ); + await response.dispose(); + } + } + }, +}); + +export function ssoSuite(title: string, define: (args: { app: Application }) => void) { + if (process.env.E2E_KEYCLOAK_URL) { + testAgainstRunningApps({ withEnv: [appConfigs.envs.withSelfServeSso] })(`${title} @sso`, ({ app }) => { + test.use({ app }); + test.setTimeout(120_000); + define({ app }); + }); + } +} + +export { expect }; diff --git a/integration/sso/keycloak.ts b/integration/sso/keycloak.ts new file mode 100644 index 00000000000..ad08a26a388 --- /dev/null +++ b/integration/sso/keycloak.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; + +import type { Page } from '@playwright/test'; + +export class Keycloak { + readonly realm = `e2e-${randomUUID()}`; + readonly domain = `${this.realm}.clerk.test`; + readonly email = `alice@${this.domain}`; + readonly password = randomUUID(); + + async signIn(page: Page, email = this.email) { + await page.locator('#username').fill(email); + await page.locator('#password').fill(this.password); + await page.locator('#kc-login').click(); + } + + constructor( + readonly baseUrl: string, + private readonly adminPassword: string, + ) {} + + private async admin(path: string, method: string, body?: unknown) { + const tokenResponse = await fetch(`${this.baseUrl}/realms/master/protocol/openid-connect/token`, { + method: 'POST', + body: new URLSearchParams({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'e2e-admin', + password: this.adminPassword, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!tokenResponse.ok) { + throw new Error(`Keycloak admin login failed (${tokenResponse.status}).`); + } + const { access_token: token } = await tokenResponse.json(); + const response = await fetch(`${this.baseUrl}/admin/realms${path}`, { + method, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok && !(method === 'DELETE' && response.status === 404)) { + throw new Error(`Keycloak ${method} ${path} failed (${response.status}): ${await response.text()}`); + } + return response.status === 200 ? response.json() : undefined; + } + + async createRealm(realm: string) { + await this.admin('', 'POST', { + realm, + enabled: true, + sslRequired: 'none', + verifyEmail: false, + registrationAllowed: false, + }); + } + + async deleteRealm(realm: string) { + await this.admin(`/${encodeURIComponent(realm)}`, 'DELETE'); + } + + async createUser(realm: string, email: string, password: string) { + await this.admin(`/${encodeURIComponent(realm)}/users`, 'POST', { + username: email, + email, + emailVerified: true, + enabled: true, + firstName: 'Alice', + lastName: 'Keycloak', + requiredActions: [], + credentials: [{ type: 'password', value: password, temporary: false }], + }); + } + + async createSamlClient(realm: string, entityId: string | undefined, acsUrl: string | undefined) { + if (!entityId || !acsUrl) { + throw new Error('A SAML client requires an entity ID and an ACS URL.'); + } + await this.admin(`/${encodeURIComponent(realm)}/clients`, 'POST', { + clientId: entityId, + protocol: 'saml', + enabled: true, + redirectUris: [acsUrl], + attributes: { + 'saml.assertion.signature': 'true', + 'saml.server.signature': 'true', + 'saml.client.signature': 'false', + 'saml.force.post.binding': 'true', + 'saml.signature.algorithm': 'RSA_SHA256', + saml_name_id_format: 'email', + saml_force_name_id_format: 'true', + }, + protocolMappers: [ + ['email', 'email'], + ['email', 'mail'], + ['firstName', 'firstName'], + ['lastName', 'lastName'], + ].map(([property, attribute]) => ({ + name: attribute, + protocol: 'saml', + protocolMapper: 'saml-user-property-mapper', + config: { + 'user.attribute': property, + 'attribute.name': attribute, + 'attribute.nameformat': 'Basic', + }, + })), + }); + } + + async metadata(realm: string) { + const response = await fetch(`${this.baseUrl}/realms/${encodeURIComponent(realm)}/protocol/saml/descriptor`, { + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + throw new Error(`Keycloak metadata failed (${response.status}).`); + } + return response.text(); + } +} diff --git a/integration/sso/scim.ts b/integration/sso/scim.ts new file mode 100644 index 00000000000..54e740de840 --- /dev/null +++ b/integration/sso/scim.ts @@ -0,0 +1,34 @@ +export const userPayload = (email: string) => ({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: email, + externalId: email, + active: true, + name: { givenName: 'Alice', familyName: 'Keycloak' }, + emails: [{ value: email, primary: true, type: 'work' }], +}); + +export const patchPayload = (operations: Array>) => ({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'], + Operations: operations, +}); + +export class Scim { + constructor( + readonly url: string, + readonly token: string, + ) {} + + async request(path: string, method = 'GET', body?: unknown) { + const response = await fetch(`${this.url.replace(/\/$/, '')}/${path}`, { + method, + headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/scim+json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error(`SCIM event ${method} ${path} failed (${response.status}).`); + } + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : null }; + } +} diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx index b882cf75c81..747811bee1b 100644 --- a/integration/templates/react-vite/src/main.tsx +++ b/integration/templates/react-vite/src/main.tsx @@ -132,7 +132,7 @@ const router = createBrowserRouter([ element: , }, { - path: '/organization-switcher', + path: '/switcher', element: , }, { diff --git a/integration/tests/components.test.ts b/integration/tests/components.test.ts index de7784c693b..0054f1e8625 100644 --- a/integration/tests/components.test.ts +++ b/integration/tests/components.test.ts @@ -60,7 +60,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component }, { name: 'OrganizationSwitcher', - path: '/organization-switcher', + path: '/switcher', fallback: 'Loading organization switcher', protected: true, }, diff --git a/integration/tests/sso/access-control.test.ts b/integration/tests/sso/access-control.test.ts new file mode 100644 index 00000000000..cebfd379232 --- /dev/null +++ b/integration/tests/sso/access-control.test.ts @@ -0,0 +1,72 @@ +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('SSO access control', ({ app }) => { + test('requires permission and enabled self-serve SSO for connection setup', async ({ + page, + context, + organization, + keycloak, + }) => { + const u = createTestUtils({ app, page, context }); + const fakeMember = { + ...u.services.users.createFakeUser(test, { withPassword: false }), + email: `member+clerk_test@member-${keycloak.realm}.clerk.test`, + }; + const member = await u.services.users.createBapiUser(fakeMember); + await u.services.clerk.organizations.createOrganizationMembership({ + organizationId: organization.id, + userId: member.id, + role: 'org:member', + }); + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(fakeMember.email); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await u.po.organizationProfile.goTo(); + + expect(await page.evaluate(() => window.Clerk.organization?.selfServeSSOEnabled)).toBe(true); + expect( + await page.evaluate(() => window.Clerk.session?.checkAuthorization({ permission: 'org:sys_entconns:manage' })), + ).toBe(false); + await expect(page.getByRole('button', { name: 'Security', exact: true })).toHaveCount(0); + + await u.services.clerk.organizations.updateOrganizationMembership({ + organizationId: organization.id, + userId: member.id, + role: 'org:admin', + }); + await u.po.organizationProfile.goTo(); + + expect(await page.evaluate(() => window.Clerk.organization?.selfServeSSOEnabled)).toBe(true); + expect( + await page.evaluate(() => window.Clerk.session?.checkAuthorization({ permission: 'org:sys_entconns:manage' })), + ).toBe(true); + await expect(page.getByRole('button', { name: 'Security', exact: true })).toBeVisible(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await expect( + page.locator('.cl-profileSection__sso').getByRole('button', { name: 'Configure', exact: true }), + ).toBeVisible(); + + await u.services.clerk.organizations.updateOrganization(organization.id, { selfServeSsoEnabled: false }); + await u.po.organizationProfile.goTo(); + + expect(await page.evaluate(() => window.Clerk.organization?.selfServeSSOEnabled)).toBe(false); + expect( + await page.evaluate(() => window.Clerk.session?.checkAuthorization({ permission: 'org:sys_entconns:manage' })), + ).toBe(true); + await expect(page.getByRole('button', { name: 'Security', exact: true })).toHaveCount(0); + }); +}); diff --git a/integration/tests/sso/configuration-tests.test.ts b/integration/tests/sso/configuration-tests.test.ts new file mode 100644 index 00000000000..86094b52a32 --- /dev/null +++ b/integration/tests/sso/configuration-tests.test.ts @@ -0,0 +1,99 @@ +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('SSO configuration results', ({ app }) => { + test('shows failure details, blocks continuation, and continues after a successful retry', async ({ + page, + organization, + keycloak, + context, + }) => { + test.setTimeout(180_000); + + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await u.po.configureSSO.configureSaml({ + domain: keycloak.domain, + metadata: await keycloak.metadata(keycloak.realm), + issuer: `${keycloak.baseUrl}/realms/${keycloak.realm}`, + signOnUrl: `${keycloak.baseUrl}/realms/${keycloak.realm}/protocol/saml`, + }); + + const { + data: [connection], + } = await u.services.clerk.enterpriseConnections.getEnterpriseConnectionList({ organizationId: organization.id }); + await keycloak.createSamlClient( + keycloak.realm, + connection.samlConnection?.spEntityId, + connection.samlConnection?.acsUrl, + ); + + const outsideEmail = `outside@outside-${keycloak.realm}.clerk.test`; + await keycloak.createUser(keycloak.realm, outsideEmail, keycloak.password); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await expect(page.locator('.cl-configureSSOTestError')).toBeVisible(); + + const failedPopup = await u.po.configureSSO.openTest(); + + await keycloak.signIn(failedPopup, outsideEmail); + + await failedPopup.waitForURL('**/v1/enterprise_connections/**/test_runs/**'); + await failedPopup.close(); + + await page.getByRole('button', { name: 'Refresh logs', exact: true }).click(); + + const failed = page.locator('.cl-configureSSOTestResultsRow').filter({ hasText: 'Failed' }); + await failed.click(); + + await expect(page.locator('.cl-drawerBody')).toContainText('saml_email_address_domain_mismatch'); + await expect(page.getByRole('button', { name: 'Activate SSO', exact: true })).toHaveCount(0); + + await page.getByRole('button', { name: 'Close drawer', exact: true }).click(); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await expect(page.locator('.cl-configureSSOTestError')).toBeVisible(); + + await context.clearCookies({ domain: new URL(keycloak.baseUrl).hostname }); + const successPopup = await u.po.configureSSO.openTest(); + + await keycloak.signIn(successPopup); + + await successPopup.waitForURL('**/v1/enterprise_connections/**/test_runs/**'); + await successPopup.close(); + + await page.getByRole('button', { name: 'Refresh logs', exact: true }).click(); + + await page.locator('.cl-configureSSOTestResultsRow').filter({ hasText: 'Success' }).click(); + + await expect( + page.locator('.cl-configureSSOTestRunParsedUserInfo').getByText(keycloak.email, { exact: true }), + ).toBeVisible(); + + await page.getByRole('button', { name: 'Close drawer', exact: true }).click(); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await expect(page.getByRole('button', { name: 'Activate SSO', exact: true })).toBeVisible(); + }); +}); diff --git a/integration/tests/sso/connection-management.test.ts b/integration/tests/sso/connection-management.test.ts new file mode 100644 index 00000000000..39b02be0376 --- /dev/null +++ b/integration/tests/sso/connection-management.test.ts @@ -0,0 +1,89 @@ +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('SSO connection management', ({ app }) => { + test('edits the name, changes status, and requires confirmation before removal', async ({ + page, + createConnection, + context, + organization, + keycloak, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await createConnection(page); + + const section = page.locator('.cl-profileSection__sso'); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await section.getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + + await page + .locator('.cl-profileSection__ssoConnectionName') + .getByRole('button', { name: 'Edit', exact: true }) + .click(); + + await expect( + page.locator('.cl-profileSection__ssoConnectionName').getByRole('button', { name: 'Save', exact: true }), + ).toBeDisabled(); + + await page.getByLabel('Name', { exact: true }).fill('Office SSO'); + await page + .locator('.cl-profileSection__ssoConnectionName') + .getByRole('button', { name: 'Save', exact: true }) + .click(); + + await page.locator('.cl-configureSSOHeaderBackButton').click(); + + await expect(section.getByText('Office SSO', { exact: true })).toBeVisible(); + + for (const [action, status] of [ + ['Deactivate', 'Inactive'], + ['Activate', 'Active'], + ] as const) { + await section.getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: action, exact: true }).click(); + + await expect(section.getByText(status, { exact: true })).toBeVisible(); + } + + await section.getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click(); + + const remove = page.getByRole('button', { name: 'Remove connection', exact: true }); + await expect(remove).toBeDisabled(); + + await page.getByPlaceholder(organization.name).fill('Wrong name'); + await expect(remove).toBeDisabled(); + + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + + await expect(section.getByText('Office SSO', { exact: true })).toBeVisible(); + + await section.getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click(); + + await page.getByPlaceholder(organization.name).fill(organization.name); + await remove.click(); + + await expect(section.getByText('Office SSO', { exact: true })).toHaveCount(0); + }); +}); diff --git a/integration/tests/sso/directory-tokens.test.ts b/integration/tests/sso/directory-tokens.test.ts new file mode 100644 index 00000000000..d2c4ef71f1b --- /dev/null +++ b/integration/tests/sso/directory-tokens.test.ts @@ -0,0 +1,72 @@ +import type { OrganizationResource } from '@clerk/shared/types'; + +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('Directory token controls', ({ app }) => { + test('generates and copies a token and hides it after reload', async ({ + page, + createConnection, + directoryIds, + context, + organization, + keycloak, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + const connection = await createConnection(page); + + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: app.serverUrl }); + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page + .locator('.cl-profileSection__directorySync') + .getByRole('button', { name: 'Configure', exact: true }) + .click(); + + const token = page.locator('.cl-configureDirectorySyncTokenInput'); + await expect(token).not.toHaveValue(''); + + const directory = await page.evaluate( + async id => (await (window.Clerk.organization as OrganizationResource).getDirectorySync(id)).id, + connection.id, + ); + directoryIds.add(directory); + + const original = await token.inputValue(); + await page.getByRole('button', { name: 'Generate new token', exact: true }).click(); + + await expect(token).not.toHaveValue(original); + await expect(token).not.toHaveValue(''); + + await token.locator('..').locator('.cl-formFieldInputCopyToClipboardButton').click(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await token.inputValue()); + + await page.reload(); + + await page.locator('.cl-profileSection__directorySync').getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + + await expect(token).toHaveValue(''); + + await page.getByRole('button', { name: 'Generate new token', exact: true }).click(); + + await expect(token).not.toHaveValue(''); + }); +}); diff --git a/integration/tests/sso/domain-verification.test.ts b/integration/tests/sso/domain-verification.test.ts new file mode 100644 index 00000000000..1dcb95c0a00 --- /dev/null +++ b/integration/tests/sso/domain-verification.test.ts @@ -0,0 +1,57 @@ +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('SSO domain verification', ({ app }) => { + test('shows DNS instructions for an unverified domain and permits a verified test domain', async ({ + page, + keycloak, + context, + organization, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + const pendingDomain = `${keycloak.realm}.invalid`; + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page.locator('.cl-profileSection__sso').getByRole('button', { name: 'Configure', exact: true }).click(); + + await page.getByLabel('Domain', { exact: true }).fill(pendingDomain); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + const pending = page.locator('.cl-configureSSOVerifyDomainCard').filter({ hasText: pendingDomain }); + await expect(pending.locator('.cl-configureSSOVerifyDomainCardTxtRecordValue')).not.toHaveValue(''); + await expect(pending.getByRole('checkbox')).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeDisabled(); + + await page.getByLabel('Domain', { exact: true }).fill(keycloak.domain); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + const verified = page.locator('.cl-configureSSOVerifyDomainCard').filter({ hasText: keycloak.domain }); + await expect(verified.getByRole('checkbox')).toBeChecked({ timeout: 60_000 }); + + await verified.getByRole('checkbox').uncheck(); + await expect(page.getByRole('button', { name: 'Continue', exact: true })).toBeDisabled(); + + await verified.getByRole('checkbox').check(); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await page.getByText('Custom SAML Provider', { exact: true }).click(); + }); +}); diff --git a/integration/tests/sso/saml-sign-in.test.ts b/integration/tests/sso/saml-sign-in.test.ts new file mode 100644 index 00000000000..07c2dc1ebca --- /dev/null +++ b/integration/tests/sso/saml-sign-in.test.ts @@ -0,0 +1,57 @@ +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('SAML sign-in', ({ app }) => { + test('signs in through the SAML redirect and signs out through the user menu', async ({ + page, + context, + keycloak, + createConnection, + organization, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await createConnection(page); + + await u.page.goToAppHome(); + + await u.po.userButton.toggleTrigger(); + + await u.po.userButton.triggerSignOut(); + + await u.po.expect.toBeSignedOut(); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(keycloak.email); + await u.po.signIn.continue(); + + await keycloak.signIn(page); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + await u.po.userButton.toggleTrigger(); + + await u.po.userButton.waitForPopover(); + await expect(page.getByText(keycloak.email, { exact: true })).toBeVisible(); + + await u.po.userButton.triggerSignOut(); + + await u.po.expect.toBeSignedOut(); + }); +}); diff --git a/integration/tests/sso/setup-wizards.test.ts b/integration/tests/sso/setup-wizards.test.ts new file mode 100644 index 00000000000..c5c68bce401 --- /dev/null +++ b/integration/tests/sso/setup-wizards.test.ts @@ -0,0 +1,166 @@ +import type { OrganizationResource } from '@clerk/shared/types'; + +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { patchPayload, Scim, userPayload } from '../../sso/scim'; +import { createTestUtils } from '../../testUtils'; + +ssoSuite('Self-serve setup wizards', ({ app }) => { + for (const mode of ['manual', 'metadata file'] as const) { + test(`configures and activates SAML through the ${mode} wizard`, async ({ + page, + context, + organization, + keycloak, + }) => { + test.setTimeout(180_000); + + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await u.po.configureSSO.configureSaml( + { + domain: keycloak.domain, + metadata: await keycloak.metadata(keycloak.realm), + issuer: `${keycloak.baseUrl}/realms/${keycloak.realm}`, + signOnUrl: `${keycloak.baseUrl}/realms/${keycloak.realm}/protocol/saml`, + }, + mode, + ); + + const { + data: [connection], + } = await u.services.clerk.enterpriseConnections.getEnterpriseConnectionList({ organizationId: organization.id }); + await keycloak.createSamlClient( + keycloak.realm, + connection.samlConnection?.spEntityId, + connection.samlConnection?.acsUrl, + ); + + const popup = await u.po.configureSSO.openTest(); + + await keycloak.signIn(popup); + + await popup.waitForURL('**/v1/enterprise_connections/**/test_runs/**'); + await popup.close(); + + await page.getByRole('button', { name: 'Refresh logs', exact: true }).click(); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await page.getByRole('button', { name: 'Activate SSO', exact: true }).click(); + + await expect(page.locator('.cl-profileSection__sso').getByText('Active', { exact: true })).toBeVisible(); + + await u.page.goToAppHome(); + + await u.po.userButton.toggleTrigger(); + + await u.po.userButton.triggerSignOut(); + + await u.po.expect.toBeSignedOut(); + await context.clearCookies({ domain: new URL(keycloak.baseUrl).hostname }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(keycloak.email); + await u.po.signIn.continue(); + + await keycloak.signIn(page); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + }); + } + + test('configures SCIM, shows provisioning activity, and hides the token after reload', async ({ + page, + createConnection, + directoryIds, + keycloak, + context, + organization, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + const connection = await createConnection(page); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page + .locator('.cl-profileSection__directorySync') + .getByRole('button', { name: 'Configure', exact: true }) + .click(); + + const endpoint = page.locator('.cl-configureDirectorySyncEndpointUrlInput'); + const bearer = page.locator('.cl-configureDirectorySyncTokenInput'); + await expect(endpoint).not.toHaveValue(''); + await expect(bearer).not.toHaveValue(''); + + const directory = await page.evaluate( + async id => (await (window.Clerk.organization as OrganizationResource).getDirectorySync(id)).id, + connection.id, + ); + directoryIds.add(directory); + + const scim = new Scim(await endpoint.inputValue(), await bearer.inputValue()); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + const created = await scim.request('Users', 'POST', userPayload(keycloak.email)); + + const provisioned = page.locator('.cl-configureDirectorySyncUsersRow').filter({ hasText: keycloak.email }); + await expect(provisioned).toContainText('Active', { timeout: 60_000 }); + + await scim.request( + `Users/${created.body.id}`, + 'PATCH', + patchPayload([{ op: 'replace', path: 'active', value: false }]), + ); + + await expect(provisioned).toContainText('Deprovisioned', { timeout: 60_000 }); + + await page.getByRole('button', { name: 'Complete', exact: true }).click(); + + await page.reload(); + + await page.locator('.cl-profileSection__directorySync').getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Edit', exact: true }).click(); + + await expect(page.locator('.cl-configureDirectorySyncTokenInput')).toHaveValue(''); + }); +}); diff --git a/integration/tests/sso/sso-bypass.test.ts b/integration/tests/sso/sso-bypass.test.ts new file mode 100644 index 00000000000..80e09338845 --- /dev/null +++ b/integration/tests/sso/sso-bypass.test.ts @@ -0,0 +1,241 @@ +import type { ClerkClient } from '@clerk/backend'; +import type { Page } from '@playwright/test'; + +import { expect, ssoSuite, test } from '../../sso/fixtures'; +import { createTestUtils } from '../../testUtils'; +import type { UserService } from '../../testUtils/usersService'; + +async function createMember( + services: { clerk: ClerkClient; users: UserService }, + organizationId: string, + email: string, + mfa = false, +) { + const fakeMember = { + ...services.users.createFakeUser(test, { withPassword: false }), + email, + }; + const member = mfa + ? await services.clerk.users.createUser({ + emailAddress: [email], + firstName: fakeMember.firstName, + lastName: fakeMember.lastName, + privateMetadata: fakeMember.privateMetadata, + skipPasswordRequirement: true, + totpSecret: 'JBSWY3DPEHPK3PXP', + backupCodes: ['backup-code-12345'], + }) + : await services.users.createBapiUser(fakeMember); + await services.clerk.organizations.createOrganizationMembership({ + organizationId, + userId: member.id, + role: 'org:member', + }); +} + +async function addByEmail(page: Page, email: string) { + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByPlaceholder("Enter the member's email address").fill(email); + await page.getByRole('button', { name: 'Add members', exact: true }).click(); +} + +ssoSuite('SSO bypass components', ({ app }) => { + test('adds a member by email, shows validation errors, searches, and removes the member', async ({ + page, + createConnection, + organization, + keycloak, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await createConnection(page); + + await createMember(u.services, organization.id, keycloak.email); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page.locator('.cl-profileSection__ssoBypass').getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Manage', exact: true }).click(); + + await addByEmail(page, `unknown@${keycloak.domain}`); + + await expect(page.locator('.cl-organizationProfileSecuritySsoBypassFailure')).toBeVisible(); + + await page.getByPlaceholder("Enter the member's email address").fill(keycloak.email); + await page.getByRole('button', { name: 'Add members', exact: true }).click(); + + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + + await addByEmail(page, keycloak.email); + + await expect(page.locator('.cl-organizationProfileSecuritySsoBypassFailure')).toBeVisible(); + + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + + await page.getByRole('searchbox', { name: 'Search users', exact: true }).fill('absent'); + await expect(page.getByRole('row').filter({ hasText: keycloak.email })).toHaveCount(0); + + await page.getByRole('searchbox', { name: 'Search users', exact: true }).fill(keycloak.email); + const row = page.getByRole('row').filter({ hasText: keycloak.email }); + await row.getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Remove', exact: true }).click(); + + await expect(row).toHaveCount(0); + + await page.getByRole('searchbox', { name: 'Search users', exact: true }).fill(''); + await expect(row).toHaveCount(0); + }); + + test('adds members by role and reports members outside the connection domain', async ({ + page, + createConnection, + organization, + keycloak, + context, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await createConnection(page); + + for (const email of [keycloak.email, `other@outside-${keycloak.realm}.clerk.test`]) { + await createMember(u.services, organization.id, email); + } + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page.locator('.cl-profileSection__ssoBypass').getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Manage', exact: true }).click(); + + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByRole('radio', { name: 'Role', exact: true }).check(); + + await page.getByRole('button', { name: 'Select role', exact: true }).click(); + await page.getByRole('option', { name: 'Member (2)', exact: true }).click(); + await page.getByRole('button', { name: 'Add members', exact: true }).click(); + + await expect(page.locator('.cl-organizationProfileSecuritySsoBypassBulkResult')).toContainText(/Added 1 member/); + await expect(page.locator('.cl-organizationProfileSecuritySsoBypassBulkResult')).toContainText( + /1 member could not be added/, + ); + + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + + await expect(page.getByRole('row').filter({ hasText: keycloak.email })).toBeVisible(); + await expect(page.getByRole('row').filter({ hasText: `other@outside-${keycloak.realm}.clerk.test` })).toHaveCount( + 0, + ); + }); + + for (const mfa of [false, true]) { + test(`signs in with an email code through the bypass controls${mfa ? ' and MFA' : ''}`, async ({ + page, + context, + createConnection, + organization, + keycloak, + }) => { + const u = createTestUtils({ app, page, context }); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(`admin+clerk_test@admin-${keycloak.realm}.clerk.test`); + await u.po.signIn.continue(); + + await u.po.signIn.enterTestOtpCode(); + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + await u.po.organizationSwitcher.goTo(); + + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + await expect(page.locator('.cl-organizationSwitcherTrigger')).toContainText(organization.name); + + await createConnection(page); + + const email = `bypass+clerk_test@${keycloak.domain}`; + await createMember(u.services, organization.id, email, mfa); + + await u.po.organizationProfile.goTo(); + + await u.po.organizationProfile.switchToSecurityTab(); + + await page.locator('.cl-profileSection__ssoBypass').getByRole('button', { name: /menu/i }).click(); + await page.getByRole('menuitem', { name: 'Manage', exact: true }).click(); + + await addByEmail(page, email); + + await page.getByRole('button', { name: 'Finish', exact: true }).click(); + + await u.page.goToAppHome(); + + await u.po.userButton.toggleTrigger(); + + await u.po.userButton.triggerSignOut(); + + await u.po.expect.toBeSignedOut(); + + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(email); + await u.po.signIn.continue(); + + await page.getByRole('link', { name: "Can't use SSO?", exact: true }).click(); + + await u.po.signIn.enterTestOtpCode(); + + if (mfa) { + await page.waitForURL(url => url.pathname.includes('/factor-two') || url.hash.includes('/factor-two')); + + await u.po.signIn.getUseAnotherMethodLink().click(); + + await page.getByRole('button', { name: /backup code/i }).click(); + + await page.getByLabel('Backup code', { exact: true }).fill('backup-code-12345'); + await u.po.signIn.continue(); + } + + await u.po.expect.toBeSignedIn(); + await u.page.waitForAppUrl('/'); + + await u.po.userButton.toggleTrigger(); + + await u.po.userButton.waitForPopover(); + await expect(page.getByText(email, { exact: true })).toBeVisible(); + }); + } +}); diff --git a/package.json b/package.json index 950eda61070..8889f1164bd 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router", "test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions", "test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @sessions", + "test:integration:sso": "node integration/scripts/runSso.mjs", + "test:integration:sso:cleanup": "node integration/scripts/runSso.mjs --cleanup", "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start* pnpm test:integration:base --grep @tanstack-react-start", "test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue", "test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run", diff --git a/packages/backend/src/api/endpoints/OrganizationApi.ts b/packages/backend/src/api/endpoints/OrganizationApi.ts index 7c571e0c035..55518a5102c 100644 --- a/packages/backend/src/api/endpoints/OrganizationApi.ts +++ b/packages/backend/src/api/endpoints/OrganizationApi.ts @@ -66,6 +66,7 @@ export type GetOrganizationParams = ( /** @inline */ export type UpdateParams = { + selfServeSsoEnabled?: boolean; /** The name to update the Organization with. */ name?: string; /** The slug to update the Organization with. */ diff --git a/packages/shared/src/react/hooks/useOrganizationDomains.tsx b/packages/shared/src/react/hooks/useOrganizationDomains.tsx index 427f90a9e8b..066868441e9 100644 --- a/packages/shared/src/react/hooks/useOrganizationDomains.tsx +++ b/packages/shared/src/react/hooks/useOrganizationDomains.tsx @@ -133,6 +133,13 @@ function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseO ); const response = query.data; + // if we're on a development instance and every domain is a clerk.test domain, only wait 500ms since we're + // (probably!) in an e2e test + const ownershipVerificationPollInterval = + clerk.instanceType === 'development' && + response?.data.every(domain => domain.name.toLowerCase().endsWith('.clerk.test')) + ? 500 + : OWNERSHIP_VERIFICATION_POLL_INTERVAL_MS; const unverifiedOwnershipDomainIds = useMemo( () => @@ -155,7 +162,7 @@ function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseO let timeoutId: ReturnType; const scheduleNext = () => { - timeoutId = setTimeout(() => void runAttempt(), OWNERSHIP_VERIFICATION_POLL_INTERVAL_MS); + timeoutId = setTimeout(() => void runAttempt(), ownershipVerificationPollInterval); }; const domainIds = unverifiedOwnershipKey.split(','); @@ -201,7 +208,7 @@ function useOrganizationDomains(params: UseOrganizationDomainsParams = {}): UseO clearTimeout(timeoutId); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [unverifiedOwnershipKey, queryEnabled]); + }, [unverifiedOwnershipKey, queryEnabled, ownershipVerificationPollInterval]); return { data: response?.data, diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx index 7cec73bce99..95d629e7f32 100644 --- a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx @@ -77,10 +77,17 @@ function useOrganizationEnterpriseConnections( const createEnterpriseConnection = useCallback( async (createParams: CreateOrganizationEnterpriseConnectionParams) => { const created = await organization?.createEnterpriseConnection(createParams); - await revalidate(); + if (created) { + await queryClient.cancelQueries({ queryKey, exact: true }); + queryClient.setQueryData(queryKey, (connections = []) => [ + ...connections.filter(connection => connection.id !== created.id), + created, + ]); + void revalidate(); + } return created; }, - [organization, revalidate], + [organization, queryClient, queryKey, revalidate], ); const updateEnterpriseConnection = useCallback( diff --git a/packages/testing/src/playwright/unstable/page-objects/configureSSO.ts b/packages/testing/src/playwright/unstable/page-objects/configureSSO.ts new file mode 100644 index 00000000000..2923383a629 --- /dev/null +++ b/packages/testing/src/playwright/unstable/page-objects/configureSSO.ts @@ -0,0 +1,66 @@ +import type { EnhancedPage } from './app'; + +type WizardMode = 'manual' | 'metadata file'; +type SamlConfiguration = { domain: string; metadata: string; issuer: string; signOnUrl: string }; +const providers: Record = { manual: 'Custom SAML Provider', 'metadata file': 'Google Workspace' }; + +async function configureIdp(page: EnhancedPage, mode: WizardMode, { metadata, issuer, signOnUrl }: SamlConfiguration) { + if (mode === 'manual') { + for (let step = 1; step <= 3; step++) { + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + } + + await page.getByRole('radio', { name: 'Configure manually', exact: true }).check(); + + await page.getByLabel('Issuer', { exact: true }).fill(issuer); + await page.getByLabel('Sign on URL', { exact: true }).fill(signOnUrl); + + const certificate = metadata.match(/<[^>]*X509Certificate[^>]*>([^<]+) ({ + configureSaml: async (config: SamlConfiguration, mode: WizardMode = 'manual') => { + await page.locator('.cl-profileSection__sso').getByRole('button', { name: 'Configure', exact: true }).click(); + + await page.getByLabel('Domain', { exact: true }).fill(config.domain); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + + await page.getByRole('button', { name: 'Continue', exact: true }).click({ timeout: 60_000 }); + + await page.getByText(providers[mode], { exact: true }).click(); + await page.getByRole('button', { name: 'Continue', exact: true }).click(); + + await configureIdp(page, mode, config); + + await page.getByRole('button', { name: 'Open test URL', exact: true }).waitFor(); + }, + openTest: async () => { + const popup = page.waitForEvent('popup'); + await page.getByRole('button', { name: 'Open test URL', exact: true }).click(); + + return popup; + }, +}); diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts index 67c6bb73ec3..d0fed161145 100644 --- a/packages/testing/src/playwright/unstable/page-objects/index.ts +++ b/packages/testing/src/playwright/unstable/page-objects/index.ts @@ -4,6 +4,7 @@ import { createAPIKeysComponentPageObject } from './apiKeys'; import { createAppPageObject } from './app'; import { createCheckoutPageObject } from './checkout'; import { createClerkPageObject } from './clerk'; +import { createConfigureSSOPageObject } from './configureSSO'; import { createExpectPageObject } from './expect'; import { createImpersonationPageObject } from './impersonation'; import { createKeylessPopoverPageObject } from './keylessPopover'; @@ -38,6 +39,7 @@ export const createPageObjects = ({ page: app, clerk: createClerkPageObject(testArgs), checkout: createCheckoutPageObject(testArgs), + configureSSO: createConfigureSSOPageObject(testArgs), expect: createExpectPageObject(testArgs), impersonation: createImpersonationPageObject(testArgs), keylessPopover: createKeylessPopoverPageObject(testArgs), diff --git a/packages/testing/src/playwright/unstable/page-objects/organizationProfile.ts b/packages/testing/src/playwright/unstable/page-objects/organizationProfile.ts index a12010811f4..333f0c5235e 100644 --- a/packages/testing/src/playwright/unstable/page-objects/organizationProfile.ts +++ b/packages/testing/src/playwright/unstable/page-objects/organizationProfile.ts @@ -12,6 +12,9 @@ export const createOrganizationProfileComponentPageObject = (testArgs: { page: E switchToBillingTab: async () => { await page.getByRole('button', { name: /^Billing$/i }).click(); }, + switchToSecurityTab: async () => { + await page.getByRole('button', { name: /^Security$/i }).click(); + }, switchToMembersTab: async () => { await page.getByRole('button', { name: /^Members$/i }).click(); }, diff --git a/turbo.json b/turbo.json index 775c85d7f26..258e8e3eb3a 100644 --- a/turbo.json +++ b/turbo.json @@ -32,6 +32,7 @@ "VITE_CLERK_*" ], "globalPassThroughEnv": [ + "E2E_KEYCLOAK_*", "AWS_SECRET_KEY", "GITHUB_TOKEN", "INTEGRATION_TEST_RUN_KEY", @@ -576,6 +577,12 @@ "outputs": ["integration/playwright-report/**"], "outputLogs": "new-only" }, + "//#test:integration:sso": { + "cache": false, + "env": ["CI", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS", "NODE_EXTRA_CA_CERTS"], + "inputs": ["integration/**", "packages/*/src/**"], + "outputs": [] + }, "//#test:integration:tanstack-react-start": { "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS", "INTEGRATION_STAGING_INSTANCE_KEYS"], "inputs": [