diff --git a/.changeset/heavy-melons-argue.md b/.changeset/heavy-melons-argue.md new file mode 100644 index 00000000000..d2baf5ee0cc --- /dev/null +++ b/.changeset/heavy-melons-argue.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add the required `provider` field to `CreateEnterpriseConnectionParams`. The Backend API has always required `provider` when creating an enterprise connection, so calls to `createEnterpriseConnection()` without it type-checked but failed at runtime. The field is typed to the supported provider values (`'saml_custom'`, `'saml_okta'`, `'saml_google'`, `'saml_microsoft'`, `'oidc_custom'`, `'oidc_github_enterprise'`, `'oidc_gitlab'`), so unsupported values are also caught at compile time. diff --git a/.changeset/tidy-donuts-attend.md b/.changeset/tidy-donuts-attend.md new file mode 100644 index 00000000000..82cccf3a5e8 --- /dev/null +++ b/.changeset/tidy-donuts-attend.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add the remaining optional enterprise connection parameters supported by the Backend API. `CreateEnterpriseConnectionParams` and `UpdateEnterpriseConnectionParams` now accept `allowOrganizationAccountLinking`, `customAttributes`, `authenticatable`, and `disableJitProvisioning` (update also accepts `disableAdditionalIdentifications`), and SAML params accept `loginHint` for configuring the `login_hint` sent to the IdP. diff --git a/.changeset/violet-planes-repeat.md b/.changeset/violet-planes-repeat.md new file mode 100644 index 00000000000..4aee6753981 --- /dev/null +++ b/.changeset/violet-planes-repeat.md @@ -0,0 +1,9 @@ +--- +'@clerk/backend': patch +--- + +Align `CreateEnterpriseConnectionParams` and `UpdateEnterpriseConnectionParams` with the Backend API contract: + +- `name` and `domains` are now required on `CreateEnterpriseConnectionParams`. The Backend API already rejected requests missing either of them, so calls that omitted these fields failed at runtime; the types now surface this at compile time. +- Deprecated `syncUserAttributes` on `CreateEnterpriseConnectionParams`. The Backend API ignores this parameter on create; use `updateEnterpriseConnection()` to set it. +- Deprecated `provider` on `UpdateEnterpriseConnectionParams`. The Backend API ignores this parameter on update; the provider cannot be changed after creation. diff --git a/integration/tests/tanstack-start/enterprise-sso.test.ts b/integration/tests/tanstack-start/enterprise-sso.test.ts index 96f83d23fdf..5f607c96b07 100644 --- a/integration/tests/tanstack-start/enterprise-sso.test.ts +++ b/integration/tests/tanstack-start/enterprise-sso.test.ts @@ -13,7 +13,6 @@ const FAKE_IDP_CERTIFICATE = /** * Helper to create and activate a SAML enterprise connection. * The Clerk API requires creating the connection first (inactive), then activating via update. - * The `provider` field is required by the API but missing from the SDK types, so we cast. */ async function createActiveEnterpriseConnection( clerk: ReturnType['services']['clerk'], @@ -28,7 +27,7 @@ async function createActiveEnterpriseConnection( idpSsoUrl: opts.idpSsoUrl, idpCertificate: FAKE_IDP_CERTIFICATE, }, - } as Parameters[0]); + }); return clerk.enterpriseConnections.updateEnterpriseConnection(conn.id, { active: true }); } diff --git a/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts b/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts index 739eeb6445c..f46f0281e8f 100644 --- a/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts +++ b/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts @@ -1,7 +1,11 @@ import { http, HttpResponse } from 'msw'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { server, validateHeaders } from '../../mock-server'; +import type { + CreateEnterpriseConnectionParams, + EnterpriseConnectionSamlLoginHintParams, +} from '../endpoints/EnterpriseConnectionApi'; import { createBackendApiClient } from '../factory'; describe('EnterpriseConnectionAPI', () => { @@ -60,6 +64,7 @@ describe('EnterpriseConnectionAPI', () => { expect(body.name).toBe('Clerk'); expect(body.domains).toEqual(['clerk.dev']); + expect(body.provider).toBe('saml_custom'); expect(body.saml).toEqual({ idp_entity_id: 'xxx', idp_metadata_url: 'https://oauth.devsuccess.app/metadata', @@ -74,6 +79,7 @@ describe('EnterpriseConnectionAPI', () => { await apiClient.enterpriseConnections.createEnterpriseConnection({ name: 'Clerk', domains: ['clerk.dev'], + provider: 'saml_custom', saml: { idpEntityId: 'xxx', idpMetadataUrl: 'https://oauth.devsuccess.app/metadata', @@ -89,6 +95,7 @@ describe('EnterpriseConnectionAPI', () => { validateHeaders(async ({ request }) => { const body = (await request.json()) as Record; + expect(body.provider).toBe('oidc_custom'); expect(body.oidc).toEqual({ discovery_url: 'https://oidc.example.com/.well-known/openid-configuration', client_id: 'client_123', @@ -107,6 +114,7 @@ describe('EnterpriseConnectionAPI', () => { await apiClient.enterpriseConnections.createEnterpriseConnection({ name: 'OIDC Connection', domains: ['example.com'], + provider: 'oidc_custom', oidc: { discoveryUrl: 'https://oidc.example.com/.well-known/openid-configuration', clientId: 'client_123', @@ -118,6 +126,82 @@ describe('EnterpriseConnectionAPI', () => { }, }); }); + + it('requires provider and rejects unsupported values at the type level', () => { + expectTypeOf<{ name: string; domains: string[] }>().not.toExtend(); + expectTypeOf<'saml_bogus'>().not.toExtend(); + expectTypeOf<'saml_okta'>().toExtend(); + }); + + it('requires name and domains at the type level', () => { + expectTypeOf<{ + name: string; + domains: string[]; + provider: 'saml_custom'; + }>().toExtend(); + expectTypeOf<{ domains: string[]; provider: 'saml_custom' }>().not.toExtend(); + expectTypeOf<{ name: string; provider: 'saml_custom' }>().not.toExtend(); + }); + + it('requires a login hint source exactly when mode is custom_attribute at the type level', () => { + expectTypeOf<{ mode: 'custom_attribute'; source: string }>().toExtend(); + expectTypeOf<{ mode: 'email_address' }>().toExtend(); + expectTypeOf<{ mode: 'custom_attribute' }>().not.toExtend(); + expectTypeOf<{ mode: 'off'; source: string }>().not.toExtend(); + }); + + it('sends provisioning, custom attribute, and login hint params in snake_case', async () => { + server.use( + http.post( + 'https://api.clerk.test/v1/enterprise_connections', + validateHeaders(async ({ request }) => { + const body = (await request.json()) as Record; + + expect(body.allow_organization_account_linking).toBe(true); + expect(body.authenticatable).toBe(false); + expect(body.disable_jit_provisioning).toBe(true); + expect(body.custom_attributes).toEqual([ + { + name: 'Employee Number', + key: 'employee_number', + sso_path: 'user.employeeNumber', + multi_valued: false, + }, + ]); + expect((body.saml as Record).login_hint).toEqual({ + mode: 'custom_attribute', + source: 'employee_number', + }); + + return HttpResponse.json(mockEnterpriseConnectionResponse); + }), + ), + ); + + await apiClient.enterpriseConnections.createEnterpriseConnection({ + name: 'Clerk', + domains: ['clerk.dev'], + provider: 'saml_custom', + allowOrganizationAccountLinking: true, + authenticatable: false, + disableJitProvisioning: true, + customAttributes: [ + { + name: 'Employee Number', + key: 'employee_number', + ssoPath: 'user.employeeNumber', + multiValued: false, + }, + ], + saml: { + idpEntityId: 'xxx', + loginHint: { + mode: 'custom_attribute', + source: 'employee_number', + }, + }, + }); + }); }); describe('updateEnterpriseConnection', () => { @@ -146,6 +230,47 @@ describe('EnterpriseConnectionAPI', () => { }, }); }); + + it('sends provisioning and custom attribute params in snake_case', async () => { + server.use( + http.patch( + 'https://api.clerk.test/v1/enterprise_connections/entconn_123', + validateHeaders(async ({ request }) => { + const body = (await request.json()) as Record; + + expect(body.sync_user_attributes).toBe(true); + expect(body.disable_additional_identifications).toBe(true); + expect(body.allow_organization_account_linking).toBe(false); + expect(body.authenticatable).toBe(true); + expect(body.disable_jit_provisioning).toBe(false); + expect(body.custom_attributes).toEqual([ + { + name: 'Department', + key: 'department', + scim_path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', + }, + ]); + + return HttpResponse.json(mockEnterpriseConnectionResponse); + }), + ), + ); + + await apiClient.enterpriseConnections.updateEnterpriseConnection('entconn_123', { + syncUserAttributes: true, + disableAdditionalIdentifications: true, + allowOrganizationAccountLinking: false, + authenticatable: true, + disableJitProvisioning: false, + customAttributes: [ + { + name: 'Department', + key: 'department', + scimPath: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', + }, + ], + }); + }); }); describe('getEnterpriseConnectionList', () => { diff --git a/packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts b/packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts index ccbcc15d0d1..644fc0c88d8 100644 --- a/packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts +++ b/packages/backend/src/api/endpoints/EnterpriseConnectionApi.ts @@ -1,4 +1,4 @@ -import type { ClerkPaginationRequest } from '@clerk/shared/types'; +import type { ClerkPaginationRequest, OrganizationEnterpriseConnectionProvider } from '@clerk/shared/types'; import { joinPaths } from '../../util/path'; import type { EnterpriseConnection } from '../resources'; @@ -49,6 +49,35 @@ export interface EnterpriseConnectionSamlAttributeMappingParams { lastName?: string | null; } +/** @inline */ +export type EnterpriseConnectionSamlLoginHintParams = + | { + /** Sends the value stored at the user `publicMetadata` key named by `source` as the `login_hint`. */ + mode: 'custom_attribute'; + /** The user `publicMetadata` key to read the `login_hint` value from. */ + source: string; + } + | { + /** How the SAML connection emits the `login_hint` sent to the IdP: `'email_address'` sends the typed identifier and `'off'` omits the `login_hint`. */ + mode: 'email_address' | 'off'; + /** Only supported when `mode` is `'custom_attribute'`. */ + source?: never; + }; + +/** @inline */ +export interface EnterpriseConnectionCustomAttributeParams { + /** The display name of the custom attribute. */ + name: string; + /** The key to store the custom attribute under. */ + key: string; + /** The SSO (SAML or OIDC) attribute path to read the value from. */ + ssoPath?: string; + /** The SCIM attribute path to read the value from. */ + scimPath?: string; + /** Whether the custom attribute holds multiple values. */ + multiValued?: boolean; +} + /** @inline */ export interface EnterpriseConnectionSamlParams { /** Whether the SAML connection allows Identity Provider (IdP) initiated flows. */ @@ -69,20 +98,35 @@ export interface EnterpriseConnectionSamlParams { idpMetadataUrl?: string; /** The IdP Single-Sign On URL for the SAML connection. */ idpSsoUrl?: string; + /** Configuration for the `login_hint` the SAML connection sends to the IdP. */ + loginHint?: EnterpriseConnectionSamlLoginHintParams; } /** @generateWithEmptyComment */ export type CreateEnterpriseConnectionParams = { /** The name of the enterprise connection. */ - name?: string; - /** The [Verified Domains](https://clerk.com/docs/guides/organizations/add-members/verified-domains) of the enterprise connection. */ - domains?: string[]; + name: string; + /** The [Verified Domains](https://clerk.com/docs/guides/organizations/add-members/verified-domains) of the enterprise connection. Must contain at least one domain. */ + domains: string[]; /** The organization ID of the enterprise connection. */ organizationId?: string; /** Whether the enterprise connection should be active. */ active?: boolean; - /** Whether the enterprise connection should sync user attributes between the IdP and Clerk. */ + /** + * Whether the enterprise connection should sync user attributes between the IdP and Clerk. + * @deprecated The Backend API does not support this parameter on create and ignores it. Use `updateEnterpriseConnection()` to set it. + */ syncUserAttributes?: boolean; + /** The identity provider (IdP) of the enterprise connection. For example, `'saml_custom'` or `'oidc_custom'`. */ + provider: OrganizationEnterpriseConnectionProvider; + /** Whether existing users who are members of the organization can link their account to their enterprise identity. If `false`, only sign-up flows apply. */ + allowOrganizationAccountLinking?: boolean; + /** The custom attribute mappings for the enterprise connection. */ + customAttributes?: EnterpriseConnectionCustomAttributeParams[]; + /** Whether the enterprise connection can be used for sign-in and sign-up. Requires the authenticatable enterprise connections feature to be enabled for the instance. */ + authenticatable?: boolean; + /** Whether Just-in-Time (JIT) provisioning of users is disabled for the enterprise connection. */ + disableJitProvisioning?: boolean; /** Configuration for if the enterprise connection uses OAuth (OIDC). */ oidc?: EnterpriseConnectionOidcParams; /** Configuration for if the enterprise connection uses SAML. */ @@ -101,7 +145,20 @@ export type UpdateEnterpriseConnectionParams = { active?: boolean; /** Whether the enterprise connection should sync user attributes between the IdP and Clerk. */ syncUserAttributes?: boolean; - /** The identity provider (IdP) of the enterprise connection. For example, `'saml_custom'` or `'oidc_custom'`. */ + /** Whether additional identifications are disabled for the enterprise connection. */ + disableAdditionalIdentifications?: boolean; + /** Whether existing users who are members of the organization can link their account to their enterprise identity. If `false`, only sign-up flows apply. */ + allowOrganizationAccountLinking?: boolean; + /** The custom attribute mappings for the enterprise connection. */ + customAttributes?: EnterpriseConnectionCustomAttributeParams[]; + /** Whether the enterprise connection can be used for sign-in and sign-up. Requires the authenticatable enterprise connections feature to be enabled for the instance. */ + authenticatable?: boolean; + /** Whether Just-in-Time (JIT) provisioning of users is disabled for the enterprise connection. */ + disableJitProvisioning?: boolean; + /** + * The identity provider (IdP) of the enterprise connection. For example, `'saml_custom'` or `'oidc_custom'`. + * @deprecated The Backend API does not support this parameter on update and ignores it. The provider cannot be changed after creation. + */ provider?: string; /** Configuration for if the enterprise connection uses OAuth (OIDC). */ oidc?: EnterpriseConnectionOidcParams;