Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dir-sync-google-credentials.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/clerk-js/src/core/resources/DirectorySync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import type {
DirectorySyncJSONSnapshot,
DirectorySyncProvider,
DirectorySyncResource,
DirectorySyncStatusJSON,
DirectorySyncStatusResource,
DirectorySyncUserJSON,
DirectorySyncUserResource,
GetDirectorySyncUsersParams,
SetDirectorySyncCredentialsParams,
UpdateDirectorySyncParams,
} from '@clerk/shared/types';

Expand All @@ -27,6 +30,7 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource
enabled!: boolean;
groupRoleMappingEnabled!: boolean;
attributeMapping: Record<string, string> = {};
credentialsConfigured: boolean | null = null;
apiKey: string | null = null;
createdAt: Date | null = null;
updatedAt: Date | null = null;
Expand Down Expand Up @@ -83,6 +87,47 @@ export class DirectorySync extends BaseResource implements DirectorySyncResource
return new DeletedObject(json);
};

setCredentials = async (params: SetDirectorySyncCredentialsParams): Promise<DirectorySyncResource> => {
const json = (
await BaseResource._fetch<DirectorySyncJSON>({
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<void> => {
await BaseResource._fetch({
path: `${this.directoryPath}/sync`,
method: 'POST',
});
};

getSyncStatus = async (): Promise<DirectorySyncStatusResource> => {
// 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<ClerkPaginatedResponse<DirectorySyncUserResource>> => {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions packages/shared/src/types/directorySync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export interface DirectorySyncJSON extends ClerkResourceJSON {
enabled: boolean;
group_role_mapping_enabled: boolean;
attribute_mapping: Record<string, string>;
/**
* 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.
Expand Down Expand Up @@ -50,6 +55,11 @@ export interface DirectorySyncResource extends ClerkResource {
groupRoleMappingEnabled: boolean;
/** The SCIM attribute paths mapped onto Clerk user attributes. */
attributeMapping: Record<string, string>;
/**
* 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
Expand Down Expand Up @@ -77,6 +87,26 @@ export interface DirectorySyncResource extends ClerkResource {
* Gets the users the identity provider has provisioned into the directory.
*/
getUsers: (params?: GetDirectorySyncUsersParams) => Promise<ClerkPaginatedResponse<DirectorySyncUserResource>>;
/**
* 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<DirectorySyncResource>;
/**
* 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<void>;
/**
* Gets the result of the directory's most recent sync. Every field is `null` before the first sync
* completes.
*/
getSyncStatus: () => Promise<DirectorySyncStatusResource>;
__internal_toSnapshot: () => DirectorySyncJSONSnapshot;
}

Expand Down Expand Up @@ -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;
};
Loading