From aa81cd78e6ad2740ece366c8ebb3e85fb5b1f006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Wed, 30 Sep 2026 10:24:19 -0300 Subject: [PATCH 1/2] feat(clerk-js): add the IdP certificate list to org enterprise connections SAML connections trust several IdP signing certificates now, but the org enterprise connection only read the primary and only wrote through saml.idpCertificate, which replaces the whole set. --- .changeset/fapi-saml-idp-certificates.md | 6 ++++++ .../core/resources/EnterpriseConnection.ts | 10 +++++++++ .../resources/__tests__/Organization.test.ts | 14 ++++++++++++- .../src/utils/enterpriseConnection.ts | 1 + .../shared/src/types/enterpriseConnection.ts | 21 +++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 .changeset/fapi-saml-idp-certificates.md diff --git a/.changeset/fapi-saml-idp-certificates.md b/.changeset/fapi-saml-idp-certificates.md new file mode 100644 index 00000000000..3d77824f7c7 --- /dev/null +++ b/.changeset/fapi-saml-idp-certificates.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +--- + +SAML enterprise connections can trust several Identity Provider signing certificates at once. `EnterpriseConnection.samlConnection` now includes `idpCertificates`, every trusted certificate with its validity window, and `organization.createEnterpriseConnection()` and `organization.updateEnterpriseConnection()` accept `saml.idpCertificates`, an array that replaces the connection's whole set. The single `saml.idpCertificate` input is deprecated in favor of the array; it keeps working and still replaces the whole set. diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index f50213ded64..985cf10c847 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -21,6 +21,11 @@ function samlNestedFromJSON(data: EnterpriseSamlConnectionNestedJSON): Enterpris idpCertificate: data.idp_certificate, idpCertificateIssuedAt: data.idp_certificate_issued_at, idpCertificateExpiresAt: data.idp_certificate_expires_at, + idpCertificates: (data.idp_certificates ?? []).map(certificate => ({ + certificate: certificate.certificate, + issuedAt: certificate.issued_at, + expiresAt: certificate.expires_at, + })), idpMetadataUrl: data.idp_metadata_url, idpMetadata: data.idp_metadata, acsUrl: data.acs_url, @@ -42,6 +47,11 @@ function samlNestedToJSON(data: EnterpriseSamlConnectionNestedResource): Enterpr idp_certificate: data.idpCertificate, idp_certificate_issued_at: data.idpCertificateIssuedAt, idp_certificate_expires_at: data.idpCertificateExpiresAt, + idp_certificates: data.idpCertificates.map(certificate => ({ + certificate: certificate.certificate, + issued_at: certificate.issuedAt, + expires_at: certificate.expiresAt, + })), idp_metadata_url: data.idpMetadataUrl, idp_metadata: data.idpMetadata, acs_url: data.acsUrl, diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index a1f253af1a3..b778e3a30fa 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -90,6 +90,10 @@ describe('Organization', () => { idp_certificate: 'MIICertificatePlaceholder', idp_certificate_issued_at: 1672531200000, idp_certificate_expires_at: 1704067200000, + idp_certificates: [ + { certificate: 'MIICertificatePlaceholder', issued_at: 1672531200000, expires_at: 1704067200000 }, + { certificate: 'MIINextCertificatePlaceholder', issued_at: null, expires_at: null }, + ], idp_metadata_url: 'https://idp.acme.com/metadata', idp_metadata: '', acs_url: 'https://clerk.example.com/v1/saml/acs', @@ -120,6 +124,13 @@ describe('Organization', () => { expect(connections).toHaveLength(1); expect(connections[0].name).toBe('Acme Corp SSO'); expect(connections[0].allowOrganizationAccountLinking).toBe(true); + expect(connections[0].samlConnection?.idpCertificates).toEqual([ + { certificate: 'MIICertificatePlaceholder', issuedAt: 1672531200000, expiresAt: 1704067200000 }, + { certificate: 'MIINextCertificatePlaceholder', issuedAt: null, expiresAt: null }, + ]); + expect(connections[0].__internal_toSnapshot().saml_connection?.idp_certificates).toEqual( + enterpriseConnectionsJSON[0].saml_connection?.idp_certificates, + ); }); it('creates an enterprise connection without forwarding organization_id in the body', async () => { @@ -157,7 +168,7 @@ describe('Organization', () => { // Even though callers may still pass this for convenience, the SDK // must not include it in the body — the org URL is authoritative. organizationId: ORG_ID, - saml: { idpEntityId: 'https://idp.example.com' }, + saml: { idpEntityId: 'https://idp.example.com', idpCertificates: ['cert_a', 'cert_b'] }, }); // @ts-ignore @@ -169,6 +180,7 @@ describe('Organization', () => { name: 'New SSO', domains: ['acme.com'], saml_idp_entity_id: 'https://idp.example.com', + saml_idp_certificates: ['cert_a', 'cert_b'], }, }); diff --git a/packages/clerk-js/src/utils/enterpriseConnection.ts b/packages/clerk-js/src/utils/enterpriseConnection.ts index c63a3bba609..c3e8f8deb7b 100644 --- a/packages/clerk-js/src/utils/enterpriseConnection.ts +++ b/packages/clerk-js/src/utils/enterpriseConnection.ts @@ -66,6 +66,7 @@ export function toEnterpriseConnectionBody( setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId); setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl); setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate); + setIfDefined(body, 'saml_idp_certificates', params.saml.idpCertificates); setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl); setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata); setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping); diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index ed5c59eaa4d..9d1fc166a68 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -55,6 +55,21 @@ export interface EnterpriseConnectionResource extends ClerkResource { __internal_toSnapshot: () => EnterpriseConnectionJSONSnapshot; } +export interface EnterpriseSamlIdpCertificateJSON { + certificate: string; + issued_at: number | null; + expires_at: number | null; +} + +export interface EnterpriseSamlIdpCertificateResource { + /** The X.509 certificate, as base64-encoded DER without PEM armor. */ + certificate: string; + /** Unix timestamp (milliseconds) of the start of the certificate validity window (X.509 NotBefore), or `null` if unknown. */ + issuedAt: number | null; + /** Unix timestamp (milliseconds) of the end of the certificate validity window (X.509 NotAfter), or `null` if unknown. */ + expiresAt: number | null; +} + export interface EnterpriseSamlConnectionNestedJSON { id: string; name: string; @@ -64,6 +79,7 @@ export interface EnterpriseSamlConnectionNestedJSON { idp_certificate: string; idp_certificate_issued_at: number; idp_certificate_expires_at: number; + idp_certificates?: EnterpriseSamlIdpCertificateJSON[]; idp_metadata_url: string; idp_metadata: string; acs_url: string; @@ -85,6 +101,8 @@ export interface EnterpriseSamlConnectionNestedResource { idpCertificateIssuedAt: number; /** Unix timestamp (milliseconds) of the end of the IdP certificate validity window (X.509 NotAfter). */ idpCertificateExpiresAt: number; + /** Every IdP signing certificate the connection trusts. The first entry is the primary, also returned as `idpCertificate`. */ + idpCertificates: EnterpriseSamlIdpCertificateResource[]; idpMetadataUrl: string; idpMetadata: string; acsUrl: string; @@ -142,7 +160,10 @@ export type MeEnterpriseConnectionProvider = OrganizationEnterpriseConnectionPro export type OrganizationEnterpriseConnectionSamlInput = { idpEntityId?: string | null; idpSsoUrl?: string | null; + /** @deprecated Use `idpCertificates` instead. */ idpCertificate?: string | null; + /** The IdP signing certificates (PEM), one per entry. Replaces every certificate the connection already trusts. */ + idpCertificates?: string[]; idpMetadataUrl?: string | null; idpMetadata?: string | null; attributeMapping?: Record | null; From e7fe91b78b99a9f6236ce3c868317324c2a12dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maur=C3=ADcio=20Antunes?= Date: Fri, 2 Oct 2026 11:28:52 -0300 Subject: [PATCH 2/2] test(clerk-js): cover saml.idpCertificates on enterprise connection update --- .../clerk-js/src/core/resources/__tests__/Organization.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index b778e3a30fa..34867fb44dc 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -258,6 +258,7 @@ describe('Organization', () => { active: false, syncUserAttributes: true, organizationId: ORG_ID, + saml: { idpCertificates: ['cert_a', 'cert_b'] }, }); // @ts-ignore @@ -268,6 +269,7 @@ describe('Organization', () => { name: 'Updated', active: false, sync_user_attributes: true, + saml_idp_certificates: ['cert_a', 'cert_b'], }, });