diff --git a/.changeset/dir-sync-google-credentials.md b/.changeset/dir-sync-google-credentials.md new file mode 100644 index 00000000000..e5c0adf9525 --- /dev/null +++ b/.changeset/dir-sync-google-credentials.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Support Google Workspace directories in Directory Sync. `DirectorySync` gains `setCredentials()` for the service account key and delegated admin the directory reads Google with, `sync()` to start a sync on demand, `getSyncStatus()` for the last sync result, and `credentialsConfigured` to tell whether a credential is stored. diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts index bb2165428b8..871856754eb 100644 --- a/packages/clerk-js/src/core/resources/DirectorySync.ts +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -6,9 +6,12 @@ import type { DirectorySyncJSONSnapshot, DirectorySyncProvider, DirectorySyncResource, + DirectorySyncStatusJSON, + DirectorySyncStatusResource, DirectorySyncUserJSON, DirectorySyncUserResource, GetDirectorySyncUsersParams, + SetDirectorySyncCredentialsParams, UpdateDirectorySyncParams, } from '@clerk/shared/types'; @@ -27,6 +30,7 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource enabled!: boolean; groupRoleMappingEnabled!: boolean; attributeMapping: Record = {}; + credentialsConfigured: boolean | null = null; apiKey: string | null = null; createdAt: Date | null = null; updatedAt: Date | null = null; @@ -83,6 +87,47 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource return new DeletedObject(json); }; + setCredentials = async (params: SetDirectorySyncCredentialsParams): Promise => { + const json = ( + await BaseResource._fetch({ + path: `${this.directoryPath}/credentials`, + method: 'POST', + body: { + service_account_json: params.serviceAccountJson, + subject_email: params.subjectEmail, + } as any, + }) + )?.response as unknown as DirectorySyncJSON; + + // The credential is deliberately not kept on the resource. It is an input + // only; the server stores it and reports `credentials_configured` back. + return new DirectorySync(json, this.organizationId); + }; + + sync = async (): Promise => { + await BaseResource._fetch({ + path: `${this.directoryPath}/sync`, + method: 'POST', + }); + }; + + getSyncStatus = async (): Promise => { + // Not a Clerk resource — it has no id or object — so it is fetched + // untyped and cast, the same way getUsers handles its paginated payload. + const res = await BaseResource._fetch({ + path: `${this.directoryPath}/sync_status`, + method: 'GET', + }); + + const json = res?.response as unknown as DirectorySyncStatusJSON | undefined; + + return { + lastSyncedAt: json?.last_synced_at ? unixEpochToDate(json.last_synced_at) : null, + lastSyncStatus: json?.last_sync_status ?? null, + lastSyncError: json?.last_sync_error ?? null, + }; + }; + getUsers = async ( params?: GetDirectorySyncUsersParams, ): Promise> => { @@ -113,6 +158,7 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource this.enabled = data.enabled; this.groupRoleMappingEnabled = data.group_role_mapping_enabled; this.attributeMapping = data.attribute_mapping ?? {}; + this.credentialsConfigured = data.credentials_configured ?? null; this.apiKey = data.api_key ?? null; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); @@ -131,6 +177,7 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource enabled: this.enabled, group_role_mapping_enabled: this.groupRoleMappingEnabled, attribute_mapping: this.attributeMapping, + credentials_configured: this.credentialsConfigured, // 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, diff --git a/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts index 81f3210e743..e1183cf0079 100644 --- a/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/DirectorySync.test.ts @@ -69,6 +69,102 @@ describe('DirectorySync', () => { expect(result.apiKey).toBe('ak_new'); }); + it('stores pull credentials and reflects the activated directory', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue( + Promise.resolve({ + response: { ...directoryJSON, provider: 'google', enabled: true, credentials_configured: true }, + }), + ); + + const result = await createDirectorySync().setCredentials({ + serviceAccountJson: '{"type":"service_account"}', + subjectEmail: 'admin@example.com', + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: `${DIRECTORY_PATH}/credentials`, + body: { + service_account_json: '{"type":"service_account"}', + subject_email: 'admin@example.com', + }, + }); + expect(result.credentialsConfigured).toBe(true); + expect(result.enabled).toBe(true); + }); + + it('never retains the uploaded credential on the resource or its snapshot', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue(Promise.resolve({ response: { ...directoryJSON, credentials_configured: true } })); + + const directory = createDirectorySync(); + const result = await directory.setCredentials({ + serviceAccountJson: '{"private_key":"-----BEGIN PRIVATE KEY-----"}', + subjectEmail: 'admin@example.com', + }); + + // The key is an input only. Snapshots can be persisted, so a private key + // must never be reachable from one. Check the receiver as well as the + // returned resource: setCredentials returns a fresh instance, so a leak + // would sit on the object the method was called on. + for (const target of [directory, result]) { + expect(JSON.stringify(target)).not.toContain('PRIVATE KEY'); + expect(JSON.stringify(target.__internal_toSnapshot())).not.toContain('PRIVATE KEY'); + } + }); + + it('reports credentialsConfigured as null for push providers, which have no credential', () => { + const directory = createDirectorySync(); + + expect(directory.credentialsConfigured).toBeNull(); + }); + + it('triggers a sync', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: null })); + + await createDirectorySync().sync(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'POST', path: `${DIRECTORY_PATH}/sync` }); + }); + + it('reads the last sync result', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue( + Promise.resolve({ + response: { last_synced_at: 1700000000000, last_sync_status: 'failed', last_sync_error: 'delegation denied' }, + }), + ); + + const result = await createDirectorySync().getSyncStatus(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', path: `${DIRECTORY_PATH}/sync_status` }); + expect(result.lastSyncedAt).toEqual(new Date(1700000000000)); + expect(result.lastSyncStatus).toBe('failed'); + expect(result.lastSyncError).toBe('delegation denied'); + }); + + it('reads an unsynced directory as null rather than an epoch date', async () => { + // @ts-ignore + BaseResource._fetch = vi + .fn() + .mockReturnValue( + Promise.resolve({ response: { last_synced_at: null, last_sync_status: null, last_sync_error: null } }), + ); + + const result = await createDirectorySync().getSyncStatus(); + + expect(result.lastSyncedAt).toBeNull(); + expect(result.lastSyncStatus).toBeNull(); + expect(result.lastSyncError).toBeNull(); + }); + it('deletes the directory', async () => { // @ts-ignore BaseResource._fetch = vi diff --git a/packages/shared/src/types/directorySync.ts b/packages/shared/src/types/directorySync.ts index 1652afba440..4d35728acdc 100644 --- a/packages/shared/src/types/directorySync.ts +++ b/packages/shared/src/types/directorySync.ts @@ -18,6 +18,11 @@ export interface DirectorySyncJSON extends ClerkResourceJSON { enabled: boolean; group_role_mapping_enabled: boolean; attribute_mapping: Record; + /** + * Whether a validated identity-provider credential is stored for this directory. Only present for + * pull-based providers; push-based directories authenticate with a bearer token and omit it. + */ + credentials_configured?: boolean | null; /** * The SCIM bearer token. Only present on create and rotate responses; it * cannot be retrieved again afterwards. @@ -50,6 +55,11 @@ export interface DirectorySyncResource extends ClerkResource { groupRoleMappingEnabled: boolean; /** The SCIM attribute paths mapped onto Clerk user attributes. */ attributeMapping: Record; + /** + * Whether a validated identity-provider credential is stored for this directory. `null` for + * push-based providers, which authenticate with a bearer token and have no credential. + */ + credentialsConfigured: boolean | null; /** * The SCIM bearer token. Only populated on the resource returned by * `Organization.createDirectorySync` and `rotateToken`; `null` everywhere @@ -77,6 +87,26 @@ export interface DirectorySyncResource extends ClerkResource { * Gets the users the identity provider has provisioned into the directory. */ getUsers: (params?: GetDirectorySyncUsersParams) => Promise>; + /** + * Stores the credential a pull-based directory reads the identity provider with, and activates the + * directory once the provider accepts it. Calling it again replaces the stored credential, which is + * how a rotated key is applied. + * + * The credential is validated against the identity provider before it is stored, so a rejected key or + * a misconfigured delegation rejects with a message describing what to fix. Surface that message: it + * is the only thing telling the administrator what is wrong with their setup. + */ + setCredentials: (params: SetDirectorySyncCredentialsParams) => Promise; + /** + * Starts a sync for a pull-based directory instead of waiting for the next scheduled one. Rejects + * while a sync is already running. + */ + sync: () => Promise; + /** + * Gets the result of the directory's most recent sync. Every field is `null` before the first sync + * completes. + */ + getSyncStatus: () => Promise; __internal_toSnapshot: () => DirectorySyncJSONSnapshot; } @@ -129,3 +159,30 @@ export type GetDirectorySyncUsersParams = { initialPage?: number; pageSize?: number; }; + +/** + * The outcome of a directory's last sync run. + */ +export type DirectorySyncRunStatus = 'running' | 'succeeded' | 'failed' | 'cancelled'; + +export interface DirectorySyncStatusJSON { + last_synced_at: number | null; + last_sync_status: DirectorySyncRunStatus | null; + last_sync_error: string | null; +} + +export interface DirectorySyncStatusResource { + /** When the last sync finished, or `null` if none has completed. */ + lastSyncedAt: Date | null; + /** The outcome of the last sync, or `null` if none has completed. */ + lastSyncStatus: DirectorySyncRunStatus | null; + /** Why the last sync failed, when it did. */ + lastSyncError: string | null; +} + +export type SetDirectorySyncCredentialsParams = { + /** The service account key, as the JSON document downloaded from the identity provider. */ + serviceAccountJson: string; + /** The directory administrator the service account impersonates when reading the directory. */ + subjectEmail: string; +};