From 8cd2850e8d9f5a50ee935c7976fceccad12cb4ac Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 9 Jan 2026 12:05:14 -0500 Subject: [PATCH] add docs and tests --- docs/google-workspace.md | 291 ++++++++++++++++++ ...uite-directory.service.integration.spec.ts | 227 ++++++++++++-- .../gsuite-directory.service.ts | 110 +++++++ 3 files changed, 607 insertions(+), 21 deletions(-) create mode 100644 docs/google-workspace.md diff --git a/docs/google-workspace.md b/docs/google-workspace.md new file mode 100644 index 00000000..52af454d --- /dev/null +++ b/docs/google-workspace.md @@ -0,0 +1,291 @@ +# Google Workspace Directory Integration + +This document provides technical documentation for the Google Workspace (formerly G Suite) directory integration in Bitwarden Directory Connector. + +## Overview + +The Google Workspace integration synchronizes users and groups from Google Workspace to Bitwarden organizations using the Google Admin SDK Directory API. The service uses a service account with domain-wide delegation to authenticate and access directory data. + +## Architecture + +### Service Location + +- **Implementation**: `src/services/directory-services/gsuite-directory.service.ts` +- **Configuration Model**: `src/models/gsuiteConfiguration.ts` +- **Integration Tests**: `src/services/directory-services/gsuite-directory.service.integration.spec.ts` + +### Authentication Flow + +The Google Workspace integration uses **OAuth 2.0 with Service Accounts** and domain-wide delegation: + +1. A service account is created in Google Cloud Console +2. The service account is granted domain-wide delegation authority +3. The service account is authorized for specific OAuth scopes in Google Workspace Admin Console +4. The Directory Connector uses the service account's private key to generate JWT tokens +5. JWT tokens are exchanged for access tokens to call the Admin SDK APIs + +### Required OAuth Scopes + +The service account must be granted the following OAuth 2.0 scopes: + +``` +https://www.googleapis.com/auth/admin.directory.user.readonly +https://www.googleapis.com/auth/admin.directory.group.readonly +https://www.googleapis.com/auth/admin.directory.group.member.readonly +``` + +## Configuration + +### Required Fields + +| Field | Description | +| ------------- | --------------------------------------------------------------------------------------- | +| `clientEmail` | Service account email address (e.g., `service-account@project.iam.gserviceaccount.com`) | +| `privateKey` | Service account private key in PEM format | +| `adminUser` | Admin user email to impersonate for domain-wide delegation | +| `domain` | Primary domain of the Google Workspace organization | + +### Optional Fields + +| Field | Description | +| ---------- | ---------------------------------------------------------- | +| `customer` | Customer ID for multi-domain organizations (rarely needed) | + +### Example Configuration + +```typescript +{ + clientEmail: "directory-connector@my-project.iam.gserviceaccount.com", + privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", + adminUser: "admin@example.com", + domain: "example.com", + customer: "" // Usually not required +} +``` + +## Setup Instructions + +### 1. Create a Service Account + +1. Go to [Google Cloud Console](https://console.cloud.google.com) +2. Create or select a project +3. Navigate to **IAM & Admin** > **Service Accounts** +4. Click **Create Service Account** +5. Enter a name and description +6. Click **Create and Continue** +7. Skip granting roles (not needed for this use case) +8. Click **Done** + +### 2. Generate Service Account Key + +1. Click on the newly created service account +2. Navigate to the **Keys** tab +3. Click **Add Key** > **Create new key** +4. Select **JSON** format +5. Click **Create** and download the key file +6. Extract `client_email` and `private_key` from the JSON file + +### 3. Enable Domain-Wide Delegation + +1. In the service account details, click **Show Advanced Settings** +2. Under **Domain-wide delegation**, click **Enable Google Workspace Domain-wide Delegation** +3. Note the **Client ID** (numeric ID) + +### 4. Authorize the Service Account in Google Workspace + +1. Go to [Google Workspace Admin Console](https://admin.google.com) +2. Navigate to **Security** > **API Controls** > **Domain-wide Delegation** +3. Click **Add new** +4. Enter the **Client ID** from step 3 +5. Enter the following OAuth scopes (comma-separated): + ``` + https://www.googleapis.com/auth/admin.directory.user.readonly, + https://www.googleapis.com/auth/admin.directory.group.readonly, + https://www.googleapis.com/auth/admin.directory.group.member.readonly + ``` +6. Click **Authorize** + +### 5. Configure Directory Connector + +Use the extracted values to configure the Directory Connector: + +- **Client Email**: From `client_email` in the JSON key file +- **Private Key**: From `private_key` in the JSON key file (keep the `\n` line breaks) +- **Admin User**: Email of a super admin user in your Google Workspace domain +- **Domain**: Your primary Google Workspace domain + +## Sync Behavior + +### User Synchronization + +The service synchronizes the following user attributes: + +| Google Workspace Field | Bitwarden Field | Notes | +| ------------------------- | --------------------------- | ----------------------------------------- | +| `id` | `referenceId`, `externalId` | User's unique Google ID | +| `primaryEmail` | `email` | Normalized to lowercase | +| `suspended` OR `archived` | `disabled` | User is disabled if suspended or archived | +| Deleted status | `deleted` | Set to true for deleted users | + +**Special Behavior:** + +- The service queries both **active users** and **deleted users** separately +- Suspended and archived users are included but marked as disabled +- Deleted users are included with the `deleted` flag set to true + +### Group Synchronization + +The service synchronizes the following group attributes: + +| Google Workspace Field | Bitwarden Field | Notes | +| ----------------------- | --------------------------- | ------------------------ | +| `id` | `referenceId`, `externalId` | Group's unique Google ID | +| `name` | `name` | Group display name | +| Members (type=USER) | `userMemberExternalIds` | Individual user members | +| Members (type=GROUP) | `groupMemberReferenceIds` | Nested group members | +| Members (type=CUSTOMER) | `userMemberExternalIds` | All domain users | + +**Member Types:** + +- **USER**: Individual user accounts (only ACTIVE status users are synced) +- **GROUP**: Nested groups (allows group hierarchy) +- **CUSTOMER**: Special member type that includes all users in the domain + +### Filtering + +The service supports Google Workspace Directory API query syntax for filtering: + +#### User Filter Examples + +``` +|orgUnitPath='/Engineering' # Users in Engineering OU +|email:john* # Users with email starting with "john" +|orgUnitPath='/Sales' email:*@example.com # Combined filter +``` + +#### Group Filter Examples + +``` +|name:Engineering* # Groups starting with "Engineering" +|email:team-* # Groups with email starting with "team-" +``` + +**Filter Syntax:** + +- Prefix with `|` for custom filters +- Use `:` for pattern matching (supports `*` wildcard) +- Combine multiple conditions with spaces (AND logic) + +### Pagination + +The service automatically handles pagination for all API calls: + +- Users API (active and deleted) +- Groups API +- Group Members API + +Each API call processes all pages using the `nextPageToken` mechanism until no more results are available. + +## Error Handling + +### Common Errors + +| Error | Cause | Resolution | +| ---------------------- | ------------------------------------- | ---------------------------------------------------------- | +| "dirConfigIncomplete" | Missing required configuration fields | Verify all required fields are provided | +| "authenticationFailed" | Invalid credentials or unauthorized | Check service account key and domain-wide delegation setup | +| API returns 401/403 | Missing OAuth scopes | Verify scopes are authorized in Admin Console | +| API returns 404 | Invalid domain or customer ID | Check domain configuration | + +### Security Considerations + +The service implements the following security measures: + +1. **Credential sanitization**: Error messages do not expose private keys or sensitive credentials +2. **Secure authentication**: Uses OAuth 2.0 with JWT tokens, not API keys +3. **Read-only access**: Only requires read-only scopes for directory data +4. **No credential logging**: Service account credentials are not logged + +## Testing + +### Integration Tests + +Integration tests are located in `src/services/directory-services/gsuite-directory.service.integration.spec.ts`. + +**Test Coverage:** + +- Basic sync (users and groups) +- Sync with filters +- Users-only sync +- Groups-only sync +- User filtering scenarios +- Group filtering scenarios +- Disabled users handling +- Group membership scenarios +- Error handling + +**Running Integration Tests:** + +Integration tests require live Google Workspace credentials: + +1. Obtain test credentials from the Bitwarden shared collection +2. Create a `.env` file in the `utils/` folder with: + ``` + GOOGLE_ADMIN_USER=admin@example.com + GOOGLE_CLIENT_EMAIL=service-account@project.iam.gserviceaccount.com + GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + GOOGLE_DOMAIN=example.com + ``` +3. Run tests: + + ```bash + # Run all integration tests (includes LDAP, Google Workspace, etc.) + npm run test:integration + + # Run only Google Workspace integration tests + npx jest gsuite-directory.service.integration.spec.ts + ``` + +**Test Data:** + +The integration tests expect specific test data in Google Workspace: + +- **Users**: 5 test users in organizational unit `/Integration testing` + - testuser1@bwrox.dev (in Group A) + - testuser2@bwrox.dev (in Groups A & B) + - testuser3@bwrox.dev (in Group B) + - testuser4@bwrox.dev (no groups) + - testuser5@bwrox.dev (disabled) + +- **Groups**: 2 test groups with name pattern `Integration*` + - Integration Test Group A + - Integration Test Group B + +## API Reference + +### Google Admin SDK APIs Used + +- **Users API**: `admin.users.list()` + - [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list) + +- **Groups API**: `admin.groups.list()` + - [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups/list) + +- **Members API**: `admin.members.list()` + - [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/list) + +### Rate Limits + +Google Workspace Admin SDK has rate limits: + +- Default: 1,500 queries per 100 seconds per user + +The service does not implement rate limiting logic; it relies on API error responses. + +## Resources + +- [Google Admin SDK Directory API Guide](https://developers.google.com/admin-sdk/directory/v1/guides) +- [Service Account Authentication](https://developers.google.com/identity/protocols/oauth2/service-account) +- [Domain-wide Delegation](https://support.google.com/a/answer/162106) +- [Google Workspace Admin Console](https://admin.google.com) +- [Bitwarden Directory Connector Documentation](https://bitwarden.com/help/directory-sync/) diff --git a/src/services/directory-services/gsuite-directory.service.integration.spec.ts b/src/services/directory-services/gsuite-directory.service.integration.spec.ts index 397e594c..35874a33 100644 --- a/src/services/directory-services/gsuite-directory.service.integration.spec.ts +++ b/src/services/directory-services/gsuite-directory.service.integration.spec.ts @@ -50,36 +50,221 @@ describe("gsuiteDirectoryService", () => { directoryService = new GSuiteDirectoryService(logService, i18nService, stateService); }); - it("syncs without using filters (includes test data)", async () => { - const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + describe("basic sync fetching users and groups", () => { + it("syncs without using filters (includes test data)", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); - const syncConfig = getSyncConfiguration({ - groups: true, - users: true, + const syncConfig = getSyncConfiguration({ + groups: true, + users: true, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result[0]).toEqual(expect.arrayContaining(groupFixtures)); + expect(result[1]).toEqual(expect.arrayContaining(userFixtures)); }); - stateService.getSync.mockResolvedValue(syncConfig); - const result = await directoryService.getEntries(true, true); + it("syncs using user and group filters (exact match for test data)", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); - expect(result[0]).toEqual(expect.arrayContaining(groupFixtures)); - expect(result[1]).toEqual(expect.arrayContaining(userFixtures)); + const syncConfig = getSyncConfiguration({ + groups: true, + users: true, + userFilter: INTEGRATION_USER_FILTER, + groupFilter: INTEGRATION_GROUP_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result).toEqual([groupFixtures, userFixtures]); + }); + + it("syncs only users when groups sync is disabled", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + groups: false, + users: true, + userFilter: INTEGRATION_USER_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result[0]).toBeUndefined(); + expect(result[1]).toEqual(expect.arrayContaining(userFixtures)); + }); + + it("syncs only groups when users sync is disabled", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + groups: true, + users: false, + groupFilter: INTEGRATION_GROUP_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result[0]).toEqual(expect.arrayContaining(groupFixtures)); + expect(result[1]).toEqual([]); + }); }); - it("syncs using user and group filters (exact match for test data)", async () => { - const directoryConfig = getGSuiteConfiguration(); - stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + describe("users", () => { + it("includes disabled users in sync results", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); - const syncConfig = getSyncConfiguration({ - groups: true, - users: true, - userFilter: INTEGRATION_USER_FILTER, - groupFilter: INTEGRATION_GROUP_FILTER, + const syncConfig = getSyncConfiguration({ + users: true, + userFilter: INTEGRATION_USER_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + const disabledUser = userFixtures.find((u) => u.email === "testuser5@bwrox.dev"); + expect(result[1]).toContainEqual(disabledUser); + expect(disabledUser.disabled).toBe(true); }); - stateService.getSync.mockResolvedValue(syncConfig); - const result = await directoryService.getEntries(true, true); + it("filters users by org unit path", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); - expect(result).toEqual([groupFixtures, userFixtures]); + const syncConfig = getSyncConfiguration({ + users: true, + userFilter: INTEGRATION_USER_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result[1]).toEqual(userFixtures); + expect(result[1].length).toBe(5); + }); + + it("filters users by email pattern", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + users: true, + userFilter: "|email:testuser1*", + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + const testuser1 = userFixtures.find((u) => u.email === "testuser1@bwrox.dev"); + expect(result[1]).toContainEqual(testuser1); + expect(result[1].length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("groups", () => { + it("filters groups by name pattern", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + groups: true, + users: true, + userFilter: INTEGRATION_USER_FILTER, + groupFilter: INTEGRATION_GROUP_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + expect(result[0]).toEqual(groupFixtures); + expect(result[0].length).toBe(2); + }); + + it("includes group members correctly", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + groups: true, + users: true, + userFilter: INTEGRATION_USER_FILTER, + groupFilter: INTEGRATION_GROUP_FILTER, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + const groupA = result[0].find((g) => g.name === "Integration Test Group A"); + expect(groupA).toBeDefined(); + expect(groupA.userMemberExternalIds.size).toBe(2); + expect(groupA.userMemberExternalIds.has("111605910541641314041")).toBe(true); + expect(groupA.userMemberExternalIds.has("111147009830456099026")).toBe(true); + + const groupB = result[0].find((g) => g.name === "Integration Test Group B"); + expect(groupB).toBeDefined(); + expect(groupB.userMemberExternalIds.size).toBe(2); + expect(groupB.userMemberExternalIds.has("111147009830456099026")).toBe(true); + expect(groupB.userMemberExternalIds.has("100150970267699397306")).toBe(true); + }); + + it("handles groups with no members", async () => { + const directoryConfig = getGSuiteConfiguration(); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + groups: true, + users: true, + userFilter: INTEGRATION_USER_FILTER, + groupFilter: "|name:Integration*", + }); + stateService.getSync.mockResolvedValue(syncConfig); + + const result = await directoryService.getEntries(true, true); + + // All test groups should have members, but ensure the code handles empty groups + expect(result[0]).toBeDefined(); + expect(Array.isArray(result[0])).toBe(true); + }); + }); + + describe("error handling", () => { + it("throws error when directory configuration is incomplete", async () => { + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue( + getGSuiteConfiguration({ + clientEmail: "", + }), + ); + + const syncConfig = getSyncConfiguration({ + users: true, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + await expect(directoryService.getEntries(true, true)).rejects.toThrow(); + }); + + it("throws error when authentication fails with invalid credentials", async () => { + const directoryConfig = getGSuiteConfiguration({ + privateKey: "-----BEGIN PRIVATE KEY-----\nINVALID_KEY\n-----END PRIVATE KEY-----\n", + }); + stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig); + + const syncConfig = getSyncConfiguration({ + users: true, + }); + stateService.getSync.mockResolvedValue(syncConfig); + + await expect(directoryService.getEntries(true, true)).rejects.toThrow(); + }); }); }); diff --git a/src/services/directory-services/gsuite-directory.service.ts b/src/services/directory-services/gsuite-directory.service.ts index 4e8be9a5..69621832 100644 --- a/src/services/directory-services/gsuite-directory.service.ts +++ b/src/services/directory-services/gsuite-directory.service.ts @@ -14,6 +14,22 @@ import { BaseDirectoryService } from "../baseDirectory.service"; import { IDirectoryService } from "./directory.service"; +/** + * Google Workspace (formerly G Suite) Directory Service + * + * This service integrates with Google Workspace to synchronize users and groups + * to Bitwarden organizations using the Google Admin SDK Directory API. + * + * @remarks + * Authentication is performed using a service account with domain-wide delegation. + * The service account must be granted the following OAuth 2.0 scopes: + * - https://www.googleapis.com/auth/admin.directory.user.readonly + * - https://www.googleapis.com/auth/admin.directory.group.readonly + * - https://www.googleapis.com/auth/admin.directory.group.member.readonly + * + * @see {@link https://developers.google.com/admin-sdk/directory/v1/guides | Google Admin SDK Directory API} + * @see {@link https://support.google.com/a/answer/162106 | Domain-wide delegation of authority} + */ export class GSuiteDirectoryService extends BaseDirectoryService implements IDirectoryService { private client: JWT; private service: admin_directory_v1.Admin; @@ -30,6 +46,29 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir this.service = google.admin("directory_v1"); } + /** + * Retrieves users and groups from Google Workspace directory + * @returns A tuple containing [groups, users] arrays + * + * @remarks + * This function: + * 1. Validates the directory type matches GSuite + * 2. Loads directory and sync configuration + * 3. Authenticates with Google Workspace using service account credentials + * 4. Retrieves users (if enabled in sync config) + * 5. Retrieves groups and their members (if enabled in sync config) + * 6. Applies any user/group filters specified in sync configuration + * + * User and group filters follow Google Workspace Directory API query syntax: + * - Use `|` prefix for custom filters (e.g., "|orgUnitPath='/Engineering'") + * - Multiple conditions can be combined with AND/OR operators + * + * @example + * ```typescript + * const [groups, users] = await service.getEntries(true, false); + * console.log(`Synced ${users.length} users and ${groups.length} groups`); + * ``` + */ async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> { const type = await this.stateService.getDirectoryType(); if (type !== DirectoryType.GSuite) { @@ -65,6 +104,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir return [groups, users]; } + /** + * Retrieves all users from Google Workspace directory + * + * @returns Array of UserEntry objects representing users in the directory + * + * @remarks + * This method performs two separate queries: + * 1. Active users (including suspended and archived) + * 2. Deleted users (marked with deleted flag) + * + * The method handles pagination automatically, fetching all pages of results. + * Users are filtered based on the userFilter specified in sync configuration. + * + * User properties mapped: + * - referenceId: User's unique Google ID + * - externalId: User's unique Google ID (same as referenceId) + * - email: User's primary email address (lowercase) + * - disabled: True if user is suspended or archived + * - deleted: True if user is deleted from the directory + */ private async getUsers(): Promise { const entries: UserEntry[] = []; const query = this.createDirectoryQuery(this.syncConfig.userFilter); @@ -132,6 +191,13 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir return entries; } + /** + * Transforms a Google Workspace user object into a UserEntry + * + * @param user - Google Workspace user object from the API + * @param deleted - Whether this user is from the deleted users list + * @returns UserEntry object or null if user data is invalid + */ private buildUser(user: admin_directory_v1.Schema$User, deleted: boolean) { if ((user.emails == null || user.emails === "") && !deleted) { return null; @@ -146,6 +212,17 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir return entry; } + /** + * Retrieves all groups from Google Workspace directory + * + * @param setFilter - Tuple of [isWhitelist, Set] for filtering groups + * @param users - Array of UserEntry objects to reference when processing members + * @returns Array of GroupEntry objects representing groups in the directory + * + * @remarks + * For each group, the method also retrieves all group members by calling the + * members API. Groups are filtered based on the groupFilter in sync configuration. + */ private async getGroups( setFilter: [boolean, Set], users: UserEntry[], @@ -185,6 +262,19 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir return entries; } + /** + * Transforms a Google Workspace group object into a GroupEntry with members + * + * @param group - Google Workspace group object from the API + * @param users - Array of UserEntry objects for reference + * @returns GroupEntry object with all members populated + * + * @remarks + * This method retrieves all members of the group, handling three member types: + * - USER: Individual user members (only active status users are included) + * - GROUP: Nested group members + * - CUSTOMER: Special type that includes all users in the domain + */ private async buildGroup(group: admin_directory_v1.Schema$Group, users: UserEntry[]) { let nextPageToken: string = null; @@ -230,6 +320,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir return entry; } + /** + * Authenticates with Google Workspace using service account credentials + * + * @throws Error if required configuration fields are missing or authentication fails + * + * @remarks + * Authentication uses a JWT with the following required fields: + * - clientEmail: Service account email address + * - privateKey: Service account private key (PEM format) + * - subject: Admin user email to impersonate (for domain-wide delegation) + * + * The service account must be configured with domain-wide delegation and granted + * the required OAuth scopes in the Google Workspace Admin Console. + * + * Optional configuration: + * - domain: Filters results to a specific domain + * - customer: Customer ID for multi-domain organizations + * + * @see {@link https://developers.google.com/identity/protocols/oauth2/service-account | Service account authentication} + */ private async auth() { if ( this.dirConfig.clientEmail == null ||