/** * Injectable client interfaces for the group-sync handler, plus their real * implementations. Keeping the handler dependent on these interfaces (not the * concrete SDKs) is what makes the reconcile logic unit-testable without AWS or * Google in the loop. */ /** Reads group membership from the Google Workspace Directory API. */ export interface DirectoryReader { /** * User email addresses of a Google Group, with nested groups flattened to their * underlying users (`includeDerivedMembership`). Non-user (GROUP) rows are excluded. */ listGroupMembers(groupEmail: string): Promise; } /** Reconciles membership of a Cognito group. */ export interface CognitoGroupTarget { /** Create the group if it does not already exist (idempotent). */ ensureGroup(groupName: string): Promise; /** Email addresses currently in the Cognito group. */ listMembers(groupName: string): Promise; addMember(groupName: string, email: string): Promise; removeMember(groupName: string, email: string): Promise; } /** Persists the freshness marker the pre-token Lambda fails closed on. */ export interface SyncStateWriter { writeLastSuccessfulSync(epochMs: number): Promise; } // --------------------------------------------------------------------------- // Real implementations (externalized at bundle time; exercised only in aws mode) // --------------------------------------------------------------------------- import { SignJWT, importPKCS8 } from 'jose'; /** Google service-account credentials (from Secrets Manager) for domain-wide delegation. */ export interface GoogleServiceAccount { client_email: string; private_key: string; /** The admin user to impersonate for Directory reads (domain-wide delegation subject). */ subject: string; } /** * Directory reader backed by a service account with domain-wide delegation. * Mints a short-lived bearer token via the OAuth2 JWT grant (signed with `jose`, * no googleapis dependency) and pages the Admin SDK members endpoint. */ export class GoogleDirectoryReader implements DirectoryReader { constructor(private readonly sa: GoogleServiceAccount) {} private async accessToken(): Promise { const now = Math.floor(Date.now() / 1000); const key = await importPKCS8(this.sa.private_key, 'RS256'); const assertion = await new SignJWT({ scope: 'https://www.googleapis.com/auth/admin.directory.group.readonly', }) .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) .setIssuer(this.sa.client_email) .setSubject(this.sa.subject) .setAudience('https://oauth2.googleapis.com/token') .setIssuedAt(now) .setExpirationTime(now + 3600) .sign(key); const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion, }), }); if (!res.ok) throw new Error(`Google token exchange failed: ${res.status}`); const json = (await res.json()) as { access_token?: string }; if (!json.access_token) throw new Error('Google token exchange returned no access_token'); return json.access_token; } async listGroupMembers(groupEmail: string): Promise { const token = await this.accessToken(); const emails: string[] = []; let pageToken: string | undefined; do { const url = new URL( `https://admin.googleapis.com/admin/directory/v1/groups/${encodeURIComponent(groupEmail)}/members`, ); url.searchParams.set('maxResults', '200'); // Flatten nested groups to their underlying users (the documented contract). // Derived members come back as type USER; the nested GROUP rows themselves // are skipped below so they are never pushed as a phantom (un-resolvable) user. url.searchParams.set('includeDerivedMembership', 'true'); if (pageToken) url.searchParams.set('pageToken', pageToken); const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`Directory members read failed for ${groupEmail}: ${res.status}`); const json = (await res.json()) as { members?: { email?: string; type?: string; status?: string }[]; nextPageToken?: string; }; for (const m of json.members ?? []) { // Only real users carry a tier scope; GROUP/derived rows resolve to no // Cognito user and would be silent no-op adds, so drop them here. if (m.email && m.status !== 'SUSPENDED' && m.type !== 'GROUP') emails.push(m.email); } pageToken = json.nextPageToken; } while (pageToken); return emails; } }