diff --git a/.changeset/major-poets-refuse.md b/.changeset/major-poets-refuse.md new file mode 100644 index 00000000000..1ebfb7f370b --- /dev/null +++ b/.changeset/major-poets-refuse.md @@ -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. diff --git a/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts b/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts index f46f0281e8f..2b125f3d527 100644 --- a/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts +++ b/packages/backend/src/api/__tests__/EnterpriseConnectionApi.test.ts @@ -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', @@ -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: '', 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, }, @@ -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); }); }); diff --git a/packages/backend/src/api/resources/EnterpriseConnection.ts b/packages/backend/src/api/resources/EnterpriseConnection.ts index 49c6362d635..d12ff01817f 100644 --- a/packages/backend/src/api/resources/EnterpriseConnection.ts +++ b/packages/backend/src/api/resources/EnterpriseConnection.ts @@ -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. */ @@ -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 { @@ -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), ); } } @@ -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. */ @@ -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 { @@ -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, ); } } @@ -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. */ @@ -163,6 +250,30 @@ 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, ) {} static fromJSON(data: EnterpriseConnectionJSON): EnterpriseConnection { @@ -170,7 +281,7 @@ export class EnterpriseConnection { data.id, data.name, data.domains, - data.organization_id, + data.organization_id ?? null, data.active, data.sync_user_attributes, data.allow_subdomains, @@ -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)), ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 298c4983d0a..87d58ab345c 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -752,30 +752,53 @@ export interface PaginatedResponseJSON { total_count?: number; } +export interface EnterpriseConnectionSamlConnectionLoginHintJSON { + mode: 'email_address' | 'custom_attribute' | 'off'; + source?: string; +} + +export interface EnterpriseConnectionCustomAttributeJSON { + name: string; + key: string; + sso_path: string; + scim_path: string; + multi_valued: boolean; +} + export interface EnterpriseConnectionSamlConnectionJSON { id: string; name: string; - idp_entity_id: string; - idp_sso_url: string; - idp_certificate: string; - idp_certificate_issued_at: number; - idp_certificate_expires_at: number; - idp_metadata_url: string; - idp_metadata: string; - acs_url: string; - sp_entity_id: string; - sp_metadata_url: string; - sync_user_attributes: boolean; + idp_entity_id?: string; + idp_sso_url?: string; + idp_certificate?: string; + idp_certificate_issued_at?: number; + idp_certificate_expires_at?: number; + idp_metadata_url?: string; + /** @deprecated The Backend API does not return this field. */ + idp_metadata?: string; + acs_url?: string; + sp_entity_id?: string; + sp_metadata_url?: string; + /** @deprecated The Backend API does not return this field on the nested SAML connection. Use the top-level `sync_user_attributes` instead. */ + sync_user_attributes?: boolean; + active: boolean; allow_subdomains: boolean; allow_idp_initiated: boolean; + force_authn: boolean; + login_hint: EnterpriseConnectionSamlConnectionLoginHintJSON; } export interface EnterpriseConnectionOauthConfigJSON { id: string; + provider_key: string; name: string; - client_id: string; - discovery_url: string; - logo_public_url: string; + client_id?: string; + discovery_url?: string; + auth_url?: string; + token_url?: string; + user_info_url?: string; + logo_public_url?: string; + requires_pkce: boolean; created_at: number; updated_at: number; } @@ -783,12 +806,19 @@ export interface EnterpriseConnectionOauthConfigJSON { export interface EnterpriseConnectionJSON extends ClerkResourceJSON { object: typeof ObjectType.EnterpriseConnection; name: string; + provider: string; + logo_public_url?: string; domains: string[]; - organization_id: string | null; + organization_id?: string | null; active: boolean; sync_user_attributes: boolean; - allow_subdomains: boolean; + /** @deprecated The Backend API does not return this field at the top level. Use `saml_connection.allow_subdomains` instead. */ + allow_subdomains?: boolean; disable_additional_identifications: boolean; + allow_organization_account_linking: boolean; + authenticatable: boolean; + disable_jit_provisioning: boolean; + custom_attributes?: EnterpriseConnectionCustomAttributeJSON[]; created_at: number; updated_at: number; saml_connection?: EnterpriseConnectionSamlConnectionJSON | null; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7c75cc3ade5..b1a8bbd3a58 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -67,8 +67,10 @@ export type { EmailJSON, EmailAddressJSON, EnterpriseConnectionJSON, + EnterpriseConnectionCustomAttributeJSON, EnterpriseConnectionOauthConfigJSON, EnterpriseConnectionSamlConnectionJSON, + EnterpriseConnectionSamlConnectionLoginHintJSON, ExternalAccountJSON, IdentificationLinkJSON, InstanceJSON, @@ -128,8 +130,10 @@ export type { Domain, EmailAddress, EnterpriseConnection, + EnterpriseConnectionCustomAttribute, EnterpriseConnectionOauthConfig, EnterpriseConnectionSamlConnection, + EnterpriseConnectionSamlConnectionLoginHint, ExternalAccount, Feature, Instance,