Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .changeset/cool-dragons-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/spotty-items-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 19 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ export const enUS: LocalizationResource = {
required: 'Required',
},
},
oidcCustom: {
claimsStep: {
headerSubtitle: 'Set the claims your identity provider includes in the ID token',
},
credentialsStep: {
headerSubtitle: 'Add your application credentials',
},
endpointsStep: {
headerSubtitle: 'Add your identity provider’s endpoints',
},
mainHeaderTitle: 'Configure your identity provider',
redirectUriStep: {
headerSubtitle: 'Create a new OIDC application in your identity provider’s dashboard',
},
},
samlCustom: {
assignUsersStep: {
headerSubtitle: 'Assign users or groups to your SAML application',
Expand Down Expand Up @@ -694,6 +709,10 @@ export const enUS: LocalizationResource = {
title: 'Reset connection',
},
selectProviderStep: {
oidc: {
groupLabel: 'OpenID Connect (OIDC)',
oidcProvider: 'OIDC Provider',
},
saml: {
customSaml: 'Custom SAML Provider',
google: 'Google Workspace',
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,10 @@ export type __internal_LocalizationResource = {
google: LocalizationValue;
microsoft: LocalizationValue;
};
oidc: {
groupLabel: LocalizationValue;
oidcProvider: LocalizationValue;
};
warning: LocalizationValue;
};
changeProviderDialog: {
Expand Down Expand Up @@ -1517,6 +1521,21 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
dismiss: LocalizationValue;
};
oidcCustom: {
mainHeaderTitle: LocalizationValue;
redirectUriStep: {
headerSubtitle: LocalizationValue;
};
claimsStep: {
headerSubtitle: LocalizationValue;
};
endpointsStep: {
headerSubtitle: LocalizationValue;
};
credentialsStep: {
headerSubtitle: LocalizationValue;
};
};
samlOkta: {
mainHeaderTitle: LocalizationValue;
createAppStep: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
EmailAddressResource,
EnterpriseConnectionResource,
EnterpriseOAuthConfigResource,
OrganizationDomainResource,
SamlAccountConnectionResource,
UserResource,
Expand All @@ -24,6 +25,14 @@ const makeSamlConnection = (overrides: Partial<SamlAccountConnectionResource> =
...overrides,
}) as SamlAccountConnectionResource;

const makeOauthConfig = (overrides: Partial<EnterpriseOAuthConfigResource> = {}): EnterpriseOAuthConfigResource =>
({
id: 'oauth_1',
name: 'oidc',
clientId: '',
...overrides,
}) as EnterpriseOAuthConfigResource;

const makeConnection = (overrides: Partial<EnterpriseConnectionResource> = {}): EnterpriseConnectionResource =>
({
id: 'enc_1',
Expand All @@ -49,6 +58,11 @@ const fullyConfiguredSaml = makeSamlConnection({
idpMetadataUrl: 'https://idp.example.com/metadata',
});

const configuredOidc = makeOauthConfig({ clientId: 'client_abc' });

const makeOidcConnection = (overrides: Partial<EnterpriseConnectionResource> = {}): EnterpriseConnectionResource =>
makeConnection({ provider: 'oidc_custom', samlConnection: null, oauthConfig: configuredOidc, ...overrides });

// Builds the entity with sensible defaults; each test overrides what it cares
// about.
const derive = (overrides: Partial<Parameters<typeof organizationEnterpriseConnection>[0]> = {}) =>
Expand Down Expand Up @@ -79,6 +93,11 @@ describe('organizationEnterpriseConnection', () => {
it('connection → its provider', () => {
expect(derive({ connection: makeConnection({ provider: 'saml_custom' }) }).provider).toBe('saml_custom');
});
it('carries a derived OIDC key verbatim — the open family, not the oidc_custom alias', () => {
// The backend derives `oidc_<slug>` from the connection name; the entity must
// expose that real value so dispatch can prefix-match it.
expect(derive({ connection: makeConnection({ provider: 'oidc_clerk_dev' }) }).provider).toBe('oidc_clerk_dev');
});
});

describe('isActive', () => {
Expand Down Expand Up @@ -122,6 +141,17 @@ describe('organizationEnterpriseConnection', () => {
}).hasMinimumConfiguration,
).toBe(false);
});
it('oidc client id present → true', () => {
expect(derive({ connection: makeOidcConnection() }).hasMinimumConfiguration).toBe(true);
});
it('oidc without oauth config → false', () => {
expect(derive({ connection: makeOidcConnection({ oauthConfig: null }) }).hasMinimumConfiguration).toBe(false);
});
it('oidc with empty client id → false', () => {
expect(
derive({ connection: makeOidcConnection({ oauthConfig: makeOauthConfig() }) }).hasMinimumConfiguration,
).toBe(false);
});
});

describe('hasSuccessfulTestRun', () => {
Expand Down Expand Up @@ -197,6 +227,17 @@ describe('organizationEnterpriseConnection', () => {
}).status,
).toBe('active');
});
it('oidc configured + successfully tested + not active → inactive', () => {
expect(
derive({
connection: makeOidcConnection({ active: false }),
hasSuccessfulTestRun: true,
}).status,
).toBe('inactive');
});
it('active oidc connection → active', () => {
expect(derive({ connection: makeOidcConnection({ active: true }) }).status).toBe('active');
});
});

it('is pure: identical inputs produce a deep-equal entity', () => {
Expand Down Expand Up @@ -247,10 +288,37 @@ describe('isEnterpriseConnectionConfigured', () => {
it('idpSsoUrl + idpEntityId present → true', () => {
expect(isEnterpriseConnectionConfigured(makeConnection({ samlConnection: fullyConfiguredSaml }))).toBe(true);
});
it('oidc with no oauth config → false', () => {
expect(isEnterpriseConnectionConfigured(makeOidcConnection({ oauthConfig: null }))).toBe(false);
});
it('oidc with empty client id → false', () => {
expect(isEnterpriseConnectionConfigured(makeOidcConnection({ oauthConfig: makeOauthConfig() }))).toBe(false);
});
it('oidc with client id present → true', () => {
expect(isEnterpriseConnectionConfigured(makeOidcConnection())).toBe(true);
});
it('branches on provider: an oidc connection is not satisfied by saml fields', () => {
expect(
isEnterpriseConnectionConfigured(
makeOidcConnection({ oauthConfig: makeOauthConfig(), samlConnection: fullyConfiguredSaml }),
),
).toBe(false);
});
it('branches on provider: a saml connection is not satisfied by oauth fields', () => {
expect(
isEnterpriseConnectionConfigured(
makeConnection({ provider: 'saml_okta', samlConnection: null, oauthConfig: configuredOidc }),
),
).toBe(false);
});
it('matches the derived `hasMinimumConfiguration` field', () => {
const connection = makeConnection({ samlConnection: fullyConfiguredSaml });
expect(isEnterpriseConnectionConfigured(connection)).toBe(derive({ connection }).hasMinimumConfiguration);
});
it('matches the derived `hasMinimumConfiguration` field for oidc', () => {
const connection = makeOidcConnection();
expect(isEnterpriseConnectionConfigured(connection)).toBe(derive({ connection }).hasMinimumConfiguration);
});
});

describe('areAllOrganizationDomainsVerified', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import type {
UserResource,
} from '@clerk/shared/types';

import type { ProviderType } from '../types';
import type { EnterpriseConnectionProviderType, OidcProviderType } from '../types';

/**
* OIDC providers are recognized by protocol prefix, never by literal: the backend
* stores a derived `oidc_<slug>` key (open family), so the read-back provider is
* not a fixed enum. SAML providers stay exact literals. Single source of the
* prefix notion — dispatch and configuration checks both read it from here.
*/
export const isOidcProvider = (provider: string): provider is OidcProviderType => provider.startsWith('oidc');

/**
* The email whose domain backs the connection: the user's primary address if
Expand Down Expand Up @@ -40,18 +48,26 @@ export type OrganizationEnterpriseConnectionStatus = 'unconfigured' | 'in_progre
* object the wizard makes every flow decision from. A snapshot of flattened booleans/values.
*/
export interface OrganizationEnterpriseConnection {
readonly provider: ProviderType | undefined;
readonly provider: EnterpriseConnectionProviderType | undefined;
readonly hasConnection: boolean;
readonly isActive: boolean;
readonly hasMinimumConfiguration: boolean;
readonly hasSuccessfulTestRun: boolean;
readonly status: OrganizationEnterpriseConnectionStatus;
}

// TODO - Update to support OpenID Connect
export const isEnterpriseConnectionConfigured = (
connection: EnterpriseConnectionResource | null | undefined,
): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId);
): boolean => {
if (!connection) {
return false;
}
// OIDC exposes only the client ID on the resource; the secret and manual endpoints are write-only.
if (isOidcProvider(connection.provider)) {
return Boolean(connection.oauthConfig?.clientId);
}
return Boolean(connection.samlConnection?.idpSsoUrl && connection.samlConnection?.idpEntityId);
};

export const areAllOrganizationDomainsVerified = (domains: OrganizationDomainResource[] | null | undefined): boolean =>
!!domains?.length && domains.every(domain => domain.ownershipVerification?.status === 'verified');
Expand Down Expand Up @@ -86,7 +102,10 @@ export const organizationEnterpriseConnection = ({
const hasMinimumConfiguration = isEnterpriseConnectionConfigured(connection);

return {
provider: connection?.provider as ProviderType | undefined,
// Boundary cast at the FAPI edge: SAML returns exact literals, OIDC an open
// `oidc_<slug>` family. An unrecognized value degrades downstream (dispatch
// falls back), so the honest open type — not the `oidc_custom` input alias — holds here.
provider: connection?.provider as EnterpriseConnectionProviderType | undefined,
hasConnection,
isActive,
hasMinimumConfiguration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// test-runs query's `enabled` and folds its loading into the global gate.

// A connection shaped enough for the umbrella's gating: the derived
// "configured" predicate reads `samlConnection.idpSsoUrl` + `idpEntityId`, and
// the active path reads `active`.
// "configured" predicate branches on `provider`, then reads
// `samlConnection.idpSsoUrl` + `idpEntityId`, and the active path reads `active`.
type MockConnection = {
id: string;
provider: string;
active?: boolean;
samlConnection?: { idpSsoUrl?: string; idpEntityId?: string } | null;
};

const configuredConnection = (id: string): MockConnection => ({
id,
provider: 'saml_okta',
samlConnection: { idpSsoUrl: 'https://idp.example.com/sso', idpEntityId: 'https://idp.example.com/entity' },
});

const unconfiguredConnection = (id: string): MockConnection => ({ id, samlConnection: null });
const unconfiguredConnection = (id: string): MockConnection => ({ id, provider: 'saml_okta', samlConnection: null });

// Mutable state the connection-source mock reads from, so a test can flip from
// "no connection at load" to "connection created mid-flow" between renders.
Expand Down Expand Up @@ -119,7 +121,7 @@ describe('useOrganizationEnterpriseConnection — test-runs gating', () => {
});

it('(a2) active (but unconfigured) connection at load → test-runs active via the isActive path', () => {
connectionsState.data = [{ id: 'ent_active', active: true, samlConnection: null }];
connectionsState.data = [{ id: 'ent_active', provider: 'saml_okta', active: true, samlConnection: null }];

const { result } = renderHook(() => useOrganizationEnterpriseConnection());

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest';

import { Flow } from '@/customizables';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, screen } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';

import type { EnterpriseConnectionProviderType } from '../../../types';

// The dispatch reads `organizationEnterpriseConnection.provider`. The nested
// sub-flows also read `enterpriseConnection` (via `Step.Footer.Reset`), which is
// left undefined so that footer self-hides in this isolated render.
const contextState = vi.hoisted(() => ({ provider: undefined as string | undefined }));

vi.mock('../../../ConfigureSSOContext', () => ({
useConfigureSSO: () => ({
enterpriseConnection: undefined,
contentRef: { current: null },
enterpriseConnectionMutations: {},
organizationEnterpriseConnection: {
provider: contextState.provider,
hasConnection: true,
},
}),
}));

import { ConfigureProviderStep, resolveConfigureSteps } from '../index';
import { OidcCustomConfigureSteps } from '../oidc';
import {
SamlCustomConfigureSteps,
SamlGoogleConfigureSteps,
SamlMicrosoftConfigureSteps,
SamlOktaConfigureSteps,
} from '../saml';

const { createFixtures } = bindCreateFixtures('ConfigureSSO');

describe('resolveConfigureSteps', () => {
it('dispatches a derived OIDC provider key to the OIDC sub-flow by prefix (not the oidc_custom literal)', () => {
// The regression: the backend returns `oidc_<slug>` derived from the connection
// name (e.g. `clerk.dev` → `oidc_clerk_dev`), never the `oidc_custom` input alias.
expect(resolveConfigureSteps('oidc_clerk_dev')).toBe(OidcCustomConfigureSteps);
expect(resolveConfigureSteps('oidc_ghe_acme')).toBe(OidcCustomConfigureSteps);
expect(resolveConfigureSteps('oidc_gitlab_ent_acme')).toBe(OidcCustomConfigureSteps);
expect(resolveConfigureSteps('oidc_custom')).toBe(OidcCustomConfigureSteps);
});

it('dispatches SAML providers by exact literal', () => {
expect(resolveConfigureSteps('saml_okta')).toBe(SamlOktaConfigureSteps);
expect(resolveConfigureSteps('saml_custom')).toBe(SamlCustomConfigureSteps);
expect(resolveConfigureSteps('saml_google')).toBe(SamlGoogleConfigureSteps);
expect(resolveConfigureSteps('saml_microsoft')).toBe(SamlMicrosoftConfigureSteps);
});

it('returns undefined for an unrecognized provider so the caller can degrade', () => {
expect(resolveConfigureSteps('ldap_enterprise' as EnterpriseConnectionProviderType)).toBeUndefined();
});
});

describe('ConfigureProviderStep', () => {
const renderStep = (wrapper: React.ComponentType<{ children?: React.ReactNode }>) =>
render(
<Flow.Root flow='configureSSO'>
<CardStateProvider>
<ConfigureProviderStep />
</CardStateProvider>
</Flow.Root>,
{ wrapper },
);

it('renders the OIDC configure steps for a derived provider key without throwing', async () => {
contextState.provider = 'oidc_clerk_dev';
const { wrapper } = await createFixtures();

renderStep(wrapper);

// The OIDC sub-flow mounts on its first step. Before the fix this threw
// `No steps found for provider: oidc_clerk_dev` and white-screened the wizard.
expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument();
});

it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => {
contextState.provider = 'ldap_enterprise';
const { wrapper } = await createFixtures();

renderStep(wrapper);

expect(await screen.findByText(/unsupported provider/i)).toBeInTheDocument();
});
});
Loading
Loading