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..34867fb44dc 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'], }, }); @@ -246,6 +258,7 @@ describe('Organization', () => { active: false, syncUserAttributes: true, organizationId: ORG_ID, + saml: { idpCertificates: ['cert_a', 'cert_b'] }, }); // @ts-ignore @@ -256,6 +269,7 @@ describe('Organization', () => { name: 'Updated', active: false, sync_user_attributes: true, + 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;