Skip to content
Open
11 changes: 11 additions & 0 deletions .changeset/major-poets-refuse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/backend': minor
---

Align the `EnterpriseConnection` response resource with what the Backend API actually returns:

- `EnterpriseConnection` now exposes `provider`, `logoPublicUrl`, `allowOrganizationAccountLinking`, `authenticatable`, `disableJitProvisioning`, and `customAttributes`.
- `EnterpriseConnectionSamlConnection` now exposes `active`, `forceAuthn`, and `loginHint`.
- `EnterpriseConnectionOauthConfig` now exposes `providerKey`, `authUrl`, `tokenUrl`, `userInfoUrl`, and `requiresPkce`.
- Deprecated properties the Backend API never returns, which were always `undefined` despite their declared types: `allowSubdomains` on `EnterpriseConnection` (use `samlConnection.allowSubdomains`), and `idpMetadata` and `syncUserAttributes` on `EnterpriseConnectionSamlConnection` (use the top-level `syncUserAttributes`).
- `organizationId` is now normalized to `null` when the Backend API omits it, matching its declared `string | null` type. Properties backed by optional API fields (for example `oauthConfig.clientId` and the SAML IdP fields) are now typed as possibly `undefined` to match runtime behavior.
55 changes: 51 additions & 4 deletions packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,26 @@ describe('EnterpriseConnectionAPI', () => {
object: 'enterprise_connection',
id: 'entconn_123',
name: 'Clerk',
provider: 'saml_custom',
logo_public_url: 'https://img.example.com/connection-logo.png',
domains: ['clerk.dev'],
organization_id: null,
created_at: 1672531200000,
updated_at: 1672531200000,
active: true,
sync_user_attributes: false,
allow_subdomains: false,
disable_additional_identifications: false,
allow_organization_account_linking: true,
authenticatable: true,
disable_jit_provisioning: false,
custom_attributes: [
{
name: 'Employee Number',
key: 'employee_number',
sso_path: 'user.employeeNumber',
scim_path: '',
multi_valued: false,
},
],
saml_connection: {
id: 'samlc_1',
name: 'Acme SAML',
Expand All @@ -35,20 +47,29 @@ describe('EnterpriseConnectionAPI', () => {
idp_certificate_issued_at: 1672531200000,
idp_certificate_expires_at: 1704067200000,
idp_metadata_url: 'https://idp.example.com/metadata',
idp_metadata: '<xml/>',
acs_url: 'https://clerk.example.com/v1/saml/acs',
sp_entity_id: 'https://clerk.example.com',
sp_metadata_url: 'https://clerk.example.com/v1/saml/metadata',
sync_user_attributes: true,
active: true,
allow_subdomains: true,
allow_idp_initiated: false,
force_authn: false,
login_hint: {
mode: 'custom_attribute',
source: 'employee_number',
},
},
oauth_config: {
id: 'eaoc_1',
provider_key: 'custom_oidc',
name: 'Acme OIDC',
client_id: 'client_abc',
discovery_url: 'https://oauth.example.com/.well-known/openid-configuration',
auth_url: 'https://oauth.example.com/authorize',
token_url: 'https://oauth.example.com/token',
user_info_url: 'https://oauth.example.com/userinfo',
logo_public_url: 'https://img.example.com/logo.png',
requires_pkce: false,
created_at: 1672531200000,
updated_at: 1672531200000,
},
Expand Down Expand Up @@ -326,17 +347,43 @@ describe('EnterpriseConnectionAPI', () => {

expect(response.id).toBe('entconn_123');
expect(response.name).toBe('Clerk');
expect(response.provider).toBe('saml_custom');
expect(response.logoPublicUrl).toBe('https://img.example.com/connection-logo.png');
expect(response.domains).toEqual(['clerk.dev']);
expect(response.active).toBe(true);
// organization_id is omitted by the Backend API when unset and normalized to null
expect(response.organizationId).toBeNull();
expect(response.allowOrganizationAccountLinking).toBe(true);
expect(response.authenticatable).toBe(true);
expect(response.disableJitProvisioning).toBe(false);
expect(response.customAttributes).toEqual([
{
name: 'Employee Number',
key: 'employee_number',
ssoPath: 'user.employeeNumber',
scimPath: '',
multiValued: false,
},
]);
expect(response.samlConnection).not.toBeNull();
expect(response.samlConnection?.id).toBe('samlc_1');
expect(response.samlConnection?.idpEntityId).toBe('https://idp.example.com');
expect(response.samlConnection?.idpCertificateIssuedAt).toBe(1672531200000);
expect(response.samlConnection?.idpCertificateExpiresAt).toBe(1704067200000);
expect(response.samlConnection?.active).toBe(true);
expect(response.samlConnection?.forceAuthn).toBe(false);
expect(response.samlConnection?.loginHint).toEqual({
mode: 'custom_attribute',
source: 'employee_number',
});
expect(response.oauthConfig).not.toBeNull();
expect(response.oauthConfig?.providerKey).toBe('custom_oidc');
expect(response.oauthConfig?.clientId).toBe('client_abc');
expect(response.oauthConfig?.discoveryUrl).toBe('https://oauth.example.com/.well-known/openid-configuration');
expect(response.oauthConfig?.authUrl).toBe('https://oauth.example.com/authorize');
expect(response.oauthConfig?.tokenUrl).toBe('https://oauth.example.com/token');
expect(response.oauthConfig?.userInfoUrl).toBe('https://oauth.example.com/userinfo');
expect(response.oauthConfig?.requiresPkce).toBe(false);
Comment on lines +350 to +386

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover omitted optional response fields.

This fixture populates every newly optional SAML/OAuth field and custom_attributes. Add a case omitting them and assert the resource exposes undefined; this locks in the intended serializer-omission behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts` around
lines 350 - 386, Add a separate test case in the EnterpriseConnectionApi
response tests using a fixture that omits the optional SAML/OAuth fields and
custom_attributes. Assert the normalized resource exposes undefined for each
omitted optional property, including the relevant fields on samlConnection and
oauthConfig, to preserve serializer-omission behavior.

Source: Coding guidelines

});
});

Expand Down
153 changes: 135 additions & 18 deletions packages/backend/src/api/resources/EnterpriseConnection.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,55 @@
import type {
EnterpriseConnectionCustomAttributeJSON,
EnterpriseConnectionJSON,
EnterpriseConnectionOauthConfigJSON,
EnterpriseConnectionSamlConnectionJSON,
EnterpriseConnectionSamlConnectionLoginHintJSON,
} from './JSON';

/**
* The `login_hint` configuration included on a Backend API {@link EnterpriseConnectionSamlConnection} response.
*/
export class EnterpriseConnectionSamlConnectionLoginHint {
constructor(
/** How the SAML connection emits the `login_hint` sent to the IdP: `'email_address'` sends the typed identifier, `'custom_attribute'` sends the value stored at the user `publicMetadata` key named by `source`, and `'off'` omits the `login_hint`. */
readonly mode: 'email_address' | 'custom_attribute' | 'off',
/** The user `publicMetadata` key the `login_hint` value is read from. Only set when `mode` is `'custom_attribute'`. */
readonly source?: string,
) {}

static fromJSON(data: EnterpriseConnectionSamlConnectionLoginHintJSON): EnterpriseConnectionSamlConnectionLoginHint {
return new EnterpriseConnectionSamlConnectionLoginHint(data.mode, data.source);
}
}

/**
* A custom attribute mapping included on a Backend API {@link EnterpriseConnection} response.
*/
export class EnterpriseConnectionCustomAttribute {
constructor(
/** The display name of the custom attribute. */
readonly name: string,
/** The key the custom attribute is stored under. */
readonly key: string,
/** The SSO (SAML or OIDC) attribute path the value is read from. */
readonly ssoPath: string,
/** The SCIM attribute path the value is read from. */
readonly scimPath: string,
/** Whether the custom attribute holds multiple values. */
readonly multiValued: boolean,
) {}

static fromJSON(data: EnterpriseConnectionCustomAttributeJSON): EnterpriseConnectionCustomAttribute {
return new EnterpriseConnectionCustomAttribute(
data.name,
data.key,
data.sso_path,
data.scim_path,
data.multi_valued,
);
}
}

/**
* The Backend `EnterpriseConnectionSamlConnection` object holds information about a SAML enterprise connection for an instance or organization.
*/
Expand All @@ -14,31 +60,43 @@ export class EnterpriseConnectionSamlConnection {
/** The name to use as a label for the connection. */
readonly name: string,
/** The Entity ID as provided by the Identity Provider (IdP). */
readonly idpEntityId: string,
readonly idpEntityId: string | undefined,
/** The Single-Sign On URL as provided by the Identity Provider (IdP). */
readonly idpSsoUrl: string,
readonly idpSsoUrl: string | undefined,
/** The X.509 certificate as provided by the Identity Provider (IdP). */
readonly idpCertificate: string,
readonly idpCertificate: string | undefined,
/** The Unix timestamp when the Identity Provider (IdP) certificate was issued. */
readonly idpCertificateIssuedAt: number,
readonly idpCertificateIssuedAt: number | undefined,
/** The Unix timestamp when the Identity Provider (IdP) certificate expires. */
readonly idpCertificateExpiresAt: number,
readonly idpCertificateExpiresAt: number | undefined,
/** The URL which serves the Identity Provider (IdP) metadata. */
readonly idpMetadataUrl: string,
/** The XML content of the Identity Provider (IdP) metadata file. */
readonly idpMetadata: string,
readonly idpMetadataUrl: string | undefined,
/**
* The XML content of the Identity Provider (IdP) metadata file.
* @deprecated The Backend API does not return this field, so it is always `undefined`.
*/
readonly idpMetadata: string | undefined,
/** The Assertion Consumer Service (ACS) URL of the connection. */
readonly acsUrl: string,
readonly acsUrl: string | undefined,
/** The Entity ID as provided by the Service Provider (Clerk). */
readonly spEntityId: string,
readonly spEntityId: string | undefined,
/** The metadata URL as provided by the Service Provider (Clerk). */
readonly spMetadataUrl: string,
/** Whether the connection syncs user attributes between the IdP and Clerk. */
readonly syncUserAttributes: boolean,
readonly spMetadataUrl: string | undefined,
/**
* Whether the connection syncs user attributes between the IdP and Clerk.
* @deprecated The Backend API does not return this field on the nested SAML connection, so it is always `undefined`. Use the top-level `syncUserAttributes` on {@link EnterpriseConnection} instead.
*/
readonly syncUserAttributes: boolean | undefined,
/** Whether users with an email address subdomain are allowed to use this connection. */
readonly allowSubdomains: boolean,
/** Whether IdP-initiated SSO is allowed. */
readonly allowIdpInitiated: boolean,
/** Whether the SAML connection is active. */
readonly active: boolean,
/** Whether the SAML connection requires force authentication. */
readonly forceAuthn: boolean,
/** The `login_hint` configuration of the SAML connection. */
readonly loginHint: EnterpriseConnectionSamlConnectionLoginHint,
) {}

static fromJSON(data: EnterpriseConnectionSamlConnectionJSON): EnterpriseConnectionSamlConnection {
Expand All @@ -58,6 +116,9 @@ export class EnterpriseConnectionSamlConnection {
data.sync_user_attributes,
data.allow_subdomains,
data.allow_idp_initiated,
data.active,
data.force_authn,
EnterpriseConnectionSamlConnectionLoginHint.fromJSON(data.login_hint),
);
}
}
Expand All @@ -78,15 +139,15 @@ export class EnterpriseConnectionOauthConfig {
/**
* The OAuth client ID.
*/
readonly clientId: string,
readonly clientId: string | undefined,
/**
* The OpenID Connect discovery URL.
*/
readonly discoveryUrl: string,
readonly discoveryUrl: string | undefined,
/**
* The public URL of the OAuth provider logo, if available.
*/
readonly logoPublicUrl: string,
readonly logoPublicUrl: string | undefined,
/**
* The date when the configuration was first created.
*/
Expand All @@ -95,6 +156,26 @@ export class EnterpriseConnectionOauthConfig {
* The date when the configuration was last updated.
*/
readonly updatedAt: number,
/**
* The OAuth provider key of the configuration. For example, `'custom_oidc'`.
*/
readonly providerKey: string,
/**
* The OAuth authorization URL.
*/
readonly authUrl: string | undefined,
/**
* The OAuth token URL.
*/
readonly tokenUrl: string | undefined,
/**
* The OAuth user info URL.
*/
readonly userInfoUrl: string | undefined,
/**
* Whether the OAuth configuration requires PKCE.
*/
readonly requiresPkce: boolean,
) {}

static fromJSON(data: EnterpriseConnectionOauthConfigJSON): EnterpriseConnectionOauthConfig {
Expand All @@ -106,6 +187,11 @@ export class EnterpriseConnectionOauthConfig {
data.logo_public_url,
data.created_at,
data.updated_at,
data.provider_key,
data.auth_url,
data.token_url,
data.user_info_url,
data.requires_pkce,
);
}
}
Expand Down Expand Up @@ -141,8 +227,9 @@ export class EnterpriseConnection {
readonly syncUserAttributes: boolean,
/**
* Whether users with an email address subdomain are allowed to use this connection or not.
* @deprecated The Backend API does not return this field at the top level, so it is always `undefined`. Use `samlConnection.allowSubdomains` instead.
*/
readonly allowSubdomains: boolean,
readonly allowSubdomains: boolean | undefined,
/**
* Whether additional identifications are disabled for this connection.
*/
Expand All @@ -163,14 +250,38 @@ export class EnterpriseConnection {
* OAuth (OIDC) configuration when the enterprise connection uses OAuth.
*/
readonly oauthConfig: EnterpriseConnectionOauthConfig | null,
/**
* The identity provider (IdP) of the connection. For example, `'saml_custom'` or `'oidc_custom'`.
*/
readonly provider: string,
/**
* The public URL of the provider logo, if available.
*/
readonly logoPublicUrl: string | undefined,
/**
* Whether existing users who are members of the organization can link their account to their enterprise identity.
*/
readonly allowOrganizationAccountLinking: boolean,
/**
* Whether the connection can be used for sign-in and sign-up.
*/
readonly authenticatable: boolean,
/**
* Whether Just-in-Time (JIT) provisioning of users is disabled for the connection.
*/
readonly disableJitProvisioning: boolean,
/**
* The custom attribute mappings of the connection. Only returned when the custom attributes feature is enabled for the instance.
*/
readonly customAttributes: EnterpriseConnectionCustomAttribute[] | undefined,
Comment on lines +253 to +276

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 '"version"|"exports"' packages/backend/package.json
ast-grep outline packages/backend/src/api/resources/EnterpriseConnection.ts --items all --type class
rg -n -C 3 '\bEnterpriseConnection(SamlConnection|OauthConfig)?\b' packages/backend/src/index.ts

Repository: clerk/javascript

Length of output: 1546


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Constructor definitions\n'
sed -n '1,340p' packages/backend/src/api/resources/EnterpriseConnection.ts | nl -ba | sed -n '1,340p'

printf '\n## Direct usages in repo\n'
rg -n 'new\s+EnterpriseConnection(SamlConnectionLoginHint|CustomAttribute|SamlConnection|OauthConfig|)\b' packages/backend/src || true

printf '\n## Package entry exports around backend index\n'
sed -n '120,190p' packages/backend/src/index.ts | nl -ba | sed -n '1,120p'

printf '\n## Changeset\n'
sed -n '1,80p' .changeset/major-poets-refuse.md | nl -ba | sed -n '1,80p'

Repository: clerk/javascript

Length of output: 222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## EnterpriseConnection.ts constructors\n'
grep -nE '^export class (EnterpriseConnectionSamlConnectionLoginHint|EnterpriseConnectionCustomAttribute|EnterpriseConnectionSamlConnection|EnterpriseConnectionOauthConfig|EnterpriseConnection)\b|^  constructor\(' packages/backend/src/api/resources/EnterpriseConnection.ts

printf '\n## Relevant source around constructors\n'
sed -n '1,340p' packages/backend/src/api/resources/EnterpriseConnection.ts | cat -n | sed -n '1,340p'

printf '\n## Public exports from backend entry\n'
grep -nE 'EnterpriseConnection(SamlConnectionLoginHint|CustomAttribute|SamlConnection|OauthConfig)?' packages/backend/src/index.ts

printf '\n## Direct constructor usages in repo\n'
rg -n 'new\s+EnterpriseConnection(SamlConnectionLoginHint|CustomAttribute|SamlConnection|OauthConfig)?\b' packages/backend/src || true

printf '\n## Changeset\n'
cat -n .changeset/major-poets-refuse.md

Repository: clerk/javascript

Length of output: 15917


Bump @clerk/backend to a major release

These exported constructors are part of the public API, so adding required trailing parameters is a breaking change. Update .changeset/major-poets-refuse.md to major, or make the new arguments backward-compatible.

  • packages/backend/src/api/resources/EnterpriseConnection.ts
  • .changeset/major-poets-refuse.md
📍 Affects 2 files
  • packages/backend/src/api/resources/EnterpriseConnection.ts#L253-L276 (this comment)
  • packages/backend/src/api/resources/EnterpriseConnection.ts#L94-L99
  • packages/backend/src/api/resources/EnterpriseConnection.ts#L159-L178
  • .changeset/major-poets-refuse.md#L2-L2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/backend/src/api/resources/EnterpriseConnection.ts` around lines 253
- 276, Update the public EnterpriseConnection constructors at
packages/backend/src/api/resources/EnterpriseConnection.ts lines 94-99, 159-178,
and 253-276 so the newly added parameters are backward-compatible, or classify
the API change as major by changing .changeset/major-poets-refuse.md line 2 to
major; ensure all affected constructor signatures follow the same choice.

Source: Coding guidelines

) {}

static fromJSON(data: EnterpriseConnectionJSON): EnterpriseConnection {
return new EnterpriseConnection(
data.id,
data.name,
data.domains,
data.organization_id,
data.organization_id ?? null,
data.active,
data.sync_user_attributes,
data.allow_subdomains,
Expand All @@ -179,6 +290,12 @@ export class EnterpriseConnection {
data.updated_at,
data.saml_connection != null ? EnterpriseConnectionSamlConnection.fromJSON(data.saml_connection) : null,
data.oauth_config != null ? EnterpriseConnectionOauthConfig.fromJSON(data.oauth_config) : null,
data.provider,
data.logo_public_url,
data.allow_organization_account_linking,
data.authenticatable,
data.disable_jit_provisioning,
data.custom_attributes?.map(attr => EnterpriseConnectionCustomAttribute.fromJSON(attr)),
);
}
}
Loading
Loading