diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts new file mode 100644 index 00000000000..8f97adbe7e8 --- /dev/null +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -0,0 +1,105 @@ +import type { + DirectorySyncJSON, + DirectorySyncJSONSnapshot, + DirectorySyncProvider, + DirectorySyncResource, + DirectorySyncUserJSON, + DirectorySyncUserResource, +} from '@clerk/shared/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './Base'; + +export class DirectorySync extends BaseResource implements DirectorySyncResource { + id!: string; + name!: string; + enterpriseConnectionId: string | null = null; + endpointUrl!: string; + provider!: DirectorySyncProvider; + enabled!: boolean; + groupRoleMappingEnabled!: boolean; + attributeMapping: Record = {}; + apiKey: string | null = null; + createdAt: Date | null = null; + updatedAt: Date | null = null; + + constructor(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: DirectorySyncJSON | DirectorySyncJSONSnapshot | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.name = data.name; + this.enterpriseConnectionId = data.enterprise_connection_id ?? null; + this.endpointUrl = data.endpoint_url; + this.provider = data.provider; + this.enabled = data.enabled; + this.groupRoleMappingEnabled = data.group_role_mapping_enabled; + this.attributeMapping = data.attribute_mapping ?? {}; + this.apiKey = data.api_key ?? null; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + + return this; + } + + public __internal_toSnapshot(): DirectorySyncJSONSnapshot { + return { + object: 'directory', + id: this.id, + name: this.name, + enterprise_connection_id: this.enterpriseConnectionId, + endpoint_url: this.endpointUrl, + provider: this.provider, + enabled: this.enabled, + group_role_mapping_enabled: this.groupRoleMappingEnabled, + attribute_mapping: this.attributeMapping, + // The bearer token is deliberately absent: snapshots may be persisted + // and the secret must never outlive the response it arrived on. + created_at: this.createdAt?.getTime() ?? 0, + updated_at: this.updatedAt?.getTime() ?? 0, + }; + } +} + +export class DirectorySyncUser extends BaseResource implements DirectorySyncUserResource { + id!: string; + userId!: string; + firstName: string | null = null; + lastName: string | null = null; + identifier: string | null = null; + imageUrl!: string; + hasImage!: boolean; + active!: boolean; + provisionedAt: Date | null = null; + updatedAt: Date | null = null; + + constructor(data: DirectorySyncUserJSON | null) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: DirectorySyncUserJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.userId = data.user_id; + this.firstName = data.first_name; + this.lastName = data.last_name; + this.identifier = data.identifier; + this.imageUrl = data.image_url; + this.hasImage = data.has_image; + this.active = data.active; + this.provisionedAt = unixEpochToDate(data.provisioned_at); + this.updatedAt = unixEpochToDate(data.updated_at); + + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index f8da6e8bdf7..b044ac48525 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -2,11 +2,16 @@ import type { AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, + CreateDirectorySyncParams, CreateOrganizationDomainParams, CreateOrganizationEnterpriseConnectionParams, CreateOrganizationParams, DeletedObjectJSON, DeletedObjectResource, + DirectorySyncJSON, + DirectorySyncResource, + DirectorySyncUserJSON, + DirectorySyncUserResource, EnterpriseConnectionJSON, EnterpriseConnectionResource, EnterpriseConnectionTestRunInitJSON, @@ -14,6 +19,7 @@ import type { EnterpriseConnectionTestRunJSON, EnterpriseConnectionTestRunResource, EnterpriseConnectionTestRunsPaginatedJSON, + GetDirectorySyncUsersParams, GetDomainsParams, GetEnterpriseConnectionsParams, GetEnterpriseConnectionTestRunsParams, @@ -37,6 +43,7 @@ import type { OrganizationResource, RoleJSON, SetOrganizationLogoParams, + UpdateDirectorySyncParams, UpdateMembershipParams, UpdateOrganizationEnterpriseConnectionParams, UpdateOrganizationParams, @@ -49,6 +56,8 @@ import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '.. import { BaseResource, DeletedObject, + DirectorySync, + DirectorySyncUser, EnterpriseConnection, EnterpriseConnectionTestRun, OrganizationInvitation, @@ -274,6 +283,95 @@ export class Organization extends BaseResource implements OrganizationResource { }; }; + getDirectorySync = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'GET', + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + createDirectorySync = async ( + enterpriseConnectionId: string, + params?: CreateDirectorySyncParams, + ): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'POST', + body: (params?.name ? { name: params.name } : {}) as any, + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + updateDirectorySync = async ( + enterpriseConnectionId: string, + params: UpdateDirectorySyncParams, + ): Promise => { + const body: Record = {}; + if (params.enabled !== undefined) { + body.enabled = params.enabled; + } + if (params.attributeMapping !== undefined) { + body.attribute_mapping = JSON.stringify(params.attributeMapping); + } + + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'PATCH', + body: body as any, + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + rotateDirectorySyncToken = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/rotate_api_key`, + method: 'POST', + }) + )?.response as unknown as DirectorySyncJSON; + + return new DirectorySync(json); + }; + + deleteDirectorySync = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory`, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + }; + + getDirectorySyncUsers = async ( + enterpriseConnectionId: string, + params?: GetDirectorySyncUsersParams, + ): Promise> => { + const res = await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/directory/users`, + method: 'GET', + search: convertPageToOffsetSearchParams(params), + }); + + const payload = res?.response as unknown as ClerkPaginatedResponse | undefined; + + return { + total_count: payload?.total_count ?? 0, + data: (payload?.data ?? []).map(row => new DirectorySyncUser(row)), + }; + }; + getMembershipRequests = async ( getRequestParam?: GetMembershipRequestParams, ): Promise> => { diff --git a/packages/clerk-js/src/core/resources/UserSettings.ts b/packages/clerk-js/src/core/resources/UserSettings.ts index 86c928f6d74..7aa0faf6394 100644 --- a/packages/clerk-js/src/core/resources/UserSettings.ts +++ b/packages/clerk-js/src/core/resources/UserSettings.ts @@ -108,6 +108,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource { enterpriseSSO: EnterpriseSSOSettings = { enabled: false, self_serve_sso: false, + self_serve_directory_sync: false, }; passkeySettings: PasskeySettingsData = { allow_autofill: false, @@ -225,7 +226,10 @@ export class UserSettings extends BaseResource implements UserSettingsResource { this.attackProtection.enumeration_protection.enabled, }, }; - this.enterpriseSSO = this.withDefault(data.enterprise_sso, this.enterpriseSSO); + this.enterpriseSSO = { + ...this.withDefault(data.enterprise_sso, this.enterpriseSSO), + self_serve_directory_sync: data.enterprise_sso?.self_serve_directory_sync ?? false, + }; this.passkeySettings = this.withDefault(data.passkey_settings, this.passkeySettings); this.passwordSettings = data.password_settings ? { 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 351629f9f6c..ad1762a1f72 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -344,4 +344,147 @@ describe('Organization', () => { expect(result.data[0].connectionType).toBe('saml'); }); }); + + describe('directory sync', () => { + const DIRECTORY_PATH = `/organizations/${ORG_ID}/enterprise_connections/ec_123/directory`; + + const directoryJSON = { + object: 'directory' as const, + id: 'scimdir_1', + name: 'Acme Okta', + enterprise_connection_id: 'ec_123', + endpoint_url: 'https://api.example.com/scim/v2', + provider: 'okta' as const, + enabled: false, + group_role_mapping_enabled: false, + attribute_mapping: { 'name.givenName': 'first_name' }, + created_at: 1700000000000, + updated_at: 1700000000000, + }; + + it('fetches the directory from the connection-scoped path', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: directoryJSON })); + + const organization = createOrganization(); + const result = await organization.getDirectorySync('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', path: DIRECTORY_PATH }); + expect(result.id).toBe('scimdir_1'); + expect(result.endpointUrl).toBe('https://api.example.com/scim/v2'); + expect(result.provider).toBe('okta'); + expect(result.attributeMapping).toEqual({ 'name.givenName': 'first_name' }); + expect(result.apiKey).toBeNull(); + }); + + it('creates the directory and exposes the show-once token', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_secret' } })); + + const organization = createOrganization(); + const result = await organization.createDirectorySync('ec_123', { name: 'Acme Okta' }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: DIRECTORY_PATH, + body: { name: 'Acme Okta' }, + }); + expect(result.apiKey).toBe('ak_secret'); + expect(result.__internal_toSnapshot()).not.toHaveProperty('api_key'); + }); + + it('updates the directory, serializing the attribute mapping as JSON', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { ...directoryJSON, enabled: true } })); + + const organization = createOrganization(); + const result = await organization.updateDirectorySync('ec_123', { + enabled: true, + attributeMapping: { 'name.familyName': 'last_name', 'name.givenName': null }, + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: DIRECTORY_PATH, + body: { + enabled: true, + attribute_mapping: JSON.stringify({ 'name.familyName': 'last_name', 'name.givenName': null }), + }, + }); + expect(result.enabled).toBe(true); + }); + + it('rotates the bearer token', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, api_key: 'ak_new' } })); + + const organization = createOrganization(); + const result = await organization.rotateDirectorySyncToken('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/rotate_api_key` }); + expect(result.apiKey).toBe('ak_new'); + }); + + it('deletes the directory', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { object: 'directory', id: 'scimdir_1', deleted: true } })); + + const organization = createOrganization(); + const result = await organization.deleteDirectorySync('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'DELETE', path: DIRECTORY_PATH }); + expect(result.id).toBe('scimdir_1'); + expect(result.deleted).toBe(true); + }); + + it('lists provisioned directory users with pagination', async () => { + const paginated = { + data: [ + { + object: 'directory_user' as const, + id: 'scimdu_1', + user_id: 'user_1', + first_name: 'Ada', + last_name: 'Lovelace', + identifier: 'ada@example.com', + image_url: '', + has_image: false, + active: true, + provisioned_at: 1700000000000, + updated_at: 1700000000000, + }, + ], + total_count: 1, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); + + const organization = createOrganization(); + const result = await organization.getDirectorySyncUsers('ec_123', { initialPage: 2, pageSize: 10 }); + + // @ts-ignore + const call = BaseResource._fetch.mock.calls[0][0]; + expect(call.method).toBe('GET'); + expect(call.path).toBe(`${DIRECTORY_PATH}/users`); + expect(call.search.get('limit')).toBe('10'); + expect(call.search.get('offset')).toBe('10'); + + expect(result.total_count).toBe(1); + expect(result.data[0].userId).toBe('user_1'); + expect(result.data[0].identifier).toBe('ada@example.com'); + expect(result.data[0].active).toBe(true); + }); + }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts b/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts index e87df8e5028..5e8f372b441 100644 --- a/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/UserSettings.test.ts @@ -25,6 +25,14 @@ describe('UserSettings', () => { }); }); + it('treats an absent self_serve_directory_sync as disabled', function () { + const sut = new UserSettings({ + enterprise_sso: { enabled: true, self_serve_sso: true }, + } as any); + + expect(sut.enterpriseSSO).toEqual({ enabled: true, self_serve_sso: true, self_serve_directory_sync: false }); + }); + it('returns enabled web3 first factors', function () { const sut = new UserSettings({ attributes: { diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index fc1232779e6..2228a4f5829 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -18,6 +18,7 @@ export * from './DeletedObject'; export * from './DisplayConfig'; export * from './EmailAddress'; export * from './EnterpriseAccount'; +export * from './DirectorySync'; export * from './EnterpriseConnection'; export * from './EnterpriseConnectionTestRun'; export * from './Environment'; diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index f3498850197..c1b4df2708d 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -536,7 +536,7 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { const withEnterpriseSso = () => { us.saml = { enabled: true }; - us.enterprise_sso = { enabled: true, self_serve_sso: false }; + us.enterprise_sso = { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }; }; const withBackupCode = (opts?: Partial) => { diff --git a/packages/shared/src/types/directorySync.ts b/packages/shared/src/types/directorySync.ts new file mode 100644 index 00000000000..bb54cb88460 --- /dev/null +++ b/packages/shared/src/types/directorySync.ts @@ -0,0 +1,108 @@ +import type { ClerkResourceJSON } from './json'; +import type { ClerkResource } from './resource'; + +/** + * The SCIM provider backing a Directory Sync directory. Derived server-side + * from the linked enterprise connection's identity provider. + */ +export type DirectorySyncProvider = 'okta' | 'entra' | 'custom' | 'google'; + +export interface DirectorySyncJSON extends ClerkResourceJSON { + object: 'directory'; + name: string; + enterprise_connection_id: string | null; + endpoint_url: string; + provider: DirectorySyncProvider; + enabled: boolean; + group_role_mapping_enabled: boolean; + attribute_mapping: Record; + /** + * The SCIM bearer token. Only present on create and rotate responses; it + * cannot be retrieved again afterwards. + */ + api_key?: string | null; + created_at: number; + updated_at: number; +} + +export type DirectorySyncJSONSnapshot = DirectorySyncJSON; + +export interface DirectorySyncResource extends ClerkResource { + /** The directory ID. */ + id: string; + /** The display name of the directory. */ + name: string; + /** The ID of the enterprise connection the directory provisions through. */ + enterpriseConnectionId: string | null; + /** The SCIM 2.0 endpoint URL the identity provider pushes to. */ + endpointUrl: string; + /** The SCIM provider, derived from the linked enterprise connection. */ + provider: DirectorySyncProvider; + /** Whether provisioning is active. */ + enabled: boolean; + /** Whether directory groups are mapped to organization roles. */ + groupRoleMappingEnabled: boolean; + /** The SCIM attribute paths mapped onto Clerk user attributes. */ + attributeMapping: Record; + /** + * The SCIM bearer token. Only populated on the resource returned by + * `createDirectorySync` and `rotateDirectorySyncToken`; `null` everywhere + * else — generate a new token if it was lost. + */ + apiKey: string | null; + /** The date when the directory was created. */ + createdAt: Date | null; + /** The date when the directory was last updated. */ + updatedAt: Date | null; + __internal_toSnapshot: () => DirectorySyncJSONSnapshot; +} + +export interface DirectorySyncUserJSON extends ClerkResourceJSON { + object: 'directory_user'; + user_id: string; + first_name: string | null; + last_name: string | null; + identifier: string | null; + image_url: string; + has_image: boolean; + active: boolean; + provisioned_at: number; + updated_at: number; +} + +/** + * A user the identity provider has provisioned into the directory, in + * public-user-data shape. + */ +export interface DirectorySyncUserResource extends ClerkResource { + id: string; + userId: string; + firstName: string | null; + lastName: string | null; + /** The user's primary email address. */ + identifier: string | null; + imageUrl: string; + hasImage: boolean; + /** `false` once the identity provider has deprovisioned the user. */ + active: boolean; + /** The date the user was provisioned into the directory. */ + provisionedAt: Date | null; + updatedAt: Date | null; +} + +export type UpdateDirectorySyncParams = { + /** Activates (`true`) or deactivates (`false`) provisioning. */ + enabled?: boolean; + /** Partial attribute mapping to merge into the stored one; `null` values remove keys. */ + attributeMapping?: Record; +}; + +export type CreateDirectorySyncParams = { + /** Optional display name; defaults to the enterprise connection's name. */ + name?: string; +}; + +export type GetDirectorySyncUsersParams = { + initialPage?: number; + pageSize?: number; +}; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 577a38ab18d..f9241426bdf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -16,6 +16,7 @@ export type * from './displayConfig'; export type * from './elementIds'; export type * from './emailAddress'; export type * from './enterpriseAccount'; +export type * from './directorySync'; export type * from './enterpriseConnection'; export type * from './enterpriseConnectionTestRun'; export type * from './environment'; diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index 53ec35142e1..9bc901e48f7 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -1,5 +1,12 @@ import type { BillingPayerMethods } from './billing'; import type { DeletedObjectResource } from './deletedObject'; +import type { + CreateDirectorySyncParams, + DirectorySyncResource, + DirectorySyncUserResource, + GetDirectorySyncUsersParams, + UpdateDirectorySyncParams, +} from './directorySync'; import type { CreateOrganizationEnterpriseConnectionParams, EnterpriseConnectionResource, @@ -221,6 +228,44 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods enterpriseConnectionId: string, params?: GetEnterpriseConnectionTestRunsParams, ) => Promise>; + /** + * Gets the Directory Sync directory bound to the given enterprise connection. The returned resource never carries + * the SCIM bearer token. + */ + getDirectorySync: (enterpriseConnectionId: string) => Promise; + /** + * Provisions Directory Sync for the given enterprise connection. The returned resource is the only place the SCIM + * bearer token (`apiKey`) is ever available; rotate it to obtain a new one. + */ + createDirectorySync: ( + enterpriseConnectionId: string, + params?: CreateDirectorySyncParams, + ) => Promise; + /** + * Updates the Directory Sync directory bound to the given enterprise connection, e.g. to activate or deactivate + * provisioning. + */ + updateDirectorySync: ( + enterpriseConnectionId: string, + params: UpdateDirectorySyncParams, + ) => Promise; + /** + * Mints a new SCIM bearer token for the directory, expiring the previous one after a short grace period. The + * returned resource is the only place the new token is available. + */ + rotateDirectorySyncToken: (enterpriseConnectionId: string) => Promise; + /** + * Deletes the connection's directory and stops provisioning. Previously provisioned members keep their + * memberships. + */ + deleteDirectorySync: (enterpriseConnectionId: string) => Promise; + /** + * Gets the users the identity provider has provisioned into the connection's directory. + */ + getDirectorySyncUsers: ( + enterpriseConnectionId: string, + params?: GetDirectorySyncUsersParams, + ) => Promise>; /** * Deletes the Organization. Only administrators can delete an Organization. * diff --git a/packages/shared/src/types/userSettings.ts b/packages/shared/src/types/userSettings.ts index ec5a599a2f6..98ba391e9c5 100644 --- a/packages/shared/src/types/userSettings.ts +++ b/packages/shared/src/types/userSettings.ts @@ -99,6 +99,8 @@ export type OAuthProviders = { export type EnterpriseSSOSettings = { enabled: boolean; self_serve_sso: boolean; + /** Whether end-users may manage Directory Sync for their enterprise connections. Absent from older backends, which means `false`. */ + self_serve_directory_sync: boolean; }; export type AttributesJSON = {