Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/fapi-saml-idp-certificates.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/clerk-js/src/core/resources/EnterpriseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand All @@ -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'],
},
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -246,6 +258,7 @@ describe('Organization', () => {
active: false,
syncUserAttributes: true,
organizationId: ORG_ID,
saml: { idpCertificates: ['cert_a', 'cert_b'] },
});

// @ts-ignore
Expand All @@ -256,6 +269,7 @@ describe('Organization', () => {
name: 'Updated',
active: false,
sync_user_attributes: true,
saml_idp_certificates: ['cert_a', 'cert_b'],
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/enterpriseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/types/enterpriseConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, unknown> | null;
Expand Down
Loading