From 49ec21cc5ea24ee4b50d5dbffa18cad1e0bac519 Mon Sep 17 00:00:00 2001 From: gabrielmeloc22 Date: Fri, 11 Sep 2026 10:19:21 -0300 Subject: [PATCH 1/2] feat(shared,js): add Google Workspace credentials and sync to DirectorySync Google Workspace directories authenticate with a stored service account credential rather than a bearer token the identity provider pushes with, and they pull on a schedule instead of being pushed to. The resource gains setCredentials, sync, getSyncStatus, and credentialsConfigured so the component can drive that shape. The uploaded key is an input only. It is never held on the resource or reachable from a snapshot, since snapshots may be persisted. --- .changeset/dir-sync-google-credentials.md | 6 ++ .../src/core/resources/DirectorySync.ts | 45 +++++++++ .../resources/__tests__/DirectorySync.test.ts | 96 +++++++++++++++++++ packages/shared/src/types/directorySync.ts | 57 +++++++++++ 4 files changed, 204 insertions(+) create mode 100644 .changeset/dir-sync-google-credentials.md 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..df3b68e67c9 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,45 @@ 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 => { + const json = ( + await BaseResource._fetch({ + path: `${this.directoryPath}/sync_status`, + method: 'GET', + }) + )?.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 +156,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 +175,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; +}; From 738e1b5a323cbf1472d2d94c1dd49bedd38c4503 Mon Sep 17 00:00:00 2001 From: gabrielmeloc22 Date: Fri, 11 Sep 2026 15:33:06 -0300 Subject: [PATCH 2/2] fix(clerk-js): fetch directory sync status without a resource generic The sync status payload has no id or object, so it does not satisfy the ClerkResourceJSON constraint on BaseResource._fetch and the declarations build failed on it. Fetched untyped and cast instead, the same way the paginated user payload alongside it is handled. Part of ORGS-1842 --- .../clerk-js/src/core/resources/DirectorySync.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/resources/DirectorySync.ts b/packages/clerk-js/src/core/resources/DirectorySync.ts index df3b68e67c9..871856754eb 100644 --- a/packages/clerk-js/src/core/resources/DirectorySync.ts +++ b/packages/clerk-js/src/core/resources/DirectorySync.ts @@ -112,12 +112,14 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource }; getSyncStatus = async (): Promise => { - const json = ( - await BaseResource._fetch({ - path: `${this.directoryPath}/sync_status`, - method: 'GET', - }) - )?.response as unknown as DirectorySyncStatusJSON | undefined; + // 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,