diff --git a/README.md b/README.md index 51b50eb..74e65e6 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,26 @@ The Cognito exporter requires these IAM permissions: ### 2. Export users and connections +The recommended path is to write a [migration package](docs/migration-package.md) that the +`import-package` orchestrator can consume in a single step: + +```bash +workos-migrate export-cognito \ + --region us-east-1 \ + --user-pool-ids us-east-1_ABC123,us-east-1_DEF456 \ + --package \ + --output-dir ./migration-cognito \ + --entities users,organizations,memberships,sso +``` + +Package mode writes the canonical layout: `users.csv`, `organizations.csv`, +`organization_memberships.csv`, the `sso/` handoff CSVs, and the `workos_upload/` projection. +By default each Cognito user pool maps to one WorkOS organization; pass +`--org-strategy connection` for one org per identity provider (memberships then become +header-only) or `--org-strategy none` to skip organization rows entirely. + +The legacy loose-CSV mode is still available for backward compatibility: + ```bash workos-migrate export-cognito \ --region us-east-1 \ @@ -320,13 +340,16 @@ workos-migrate export-cognito \ Options: -- `--entities ` - Comma-separated entities to export: `connections`, `users` (default: both) +- `--package` - Write a migration package (recommended). Without this flag, loose CSVs are written. +- `--entities ` - Loose mode: `connections,users`. Package mode: `users,organizations,memberships,sso`. +- `--org-strategy ` - Package mode only: `user-pool` (default), `connection`, or `none`. - `--output-dir ` - Output directory for CSV files (default: current directory) - `--saml-custom-entity-id-template ` - Template for SAML custom Entity ID (default: `urn:amazon:cognito:sp:{user_pool_id}`) - `--saml-custom-acs-url-template ` - Template for SAML custom ACS URL (placeholders: `{provider_name}`, `{user_pool_id}`, `{region}`) - `--oidc-custom-redirect-uri-template ` - Template for OIDC custom redirect URI +- `--skip-external-provider-users` - Skip Cognito users whose `userStatus=EXTERNAL_PROVIDER` (default in package mode; opt-in in loose mode). -The export produces: +Loose mode produces: - `workos_saml_connections.csv` - SAML SSO connections - `workos_oidc_connections.csv` - OIDC SSO connections diff --git a/dist/cli/commands/export-cognito.js b/dist/cli/commands/export-cognito.js index 7737ad3..03d6718 100644 --- a/dist/cli/commands/export-cognito.js +++ b/dist/cli/commands/export-cognito.js @@ -6,8 +6,10 @@ export function registerExportCognitoCommand(program) { .description('Export users and SSO connections from AWS Cognito user pools') .requiredOption('--region ', 'AWS region (e.g. us-east-1)', process.env.AWS_REGION) .requiredOption('--user-pool-ids ', 'Comma-separated Cognito user pool IDs', process.env.COGNITO_USER_POOL_IDS) - .option('--entities ', 'Comma-separated entities to export (connections,users)', 'connections,users') + .option('--entities ', 'Comma-separated entities (loose mode: connections,users; package mode: users,organizations,memberships,sso)', 'connections,users') .option('--output-dir ', 'Output directory for CSV files', '.') + .option('--package', 'Write a provider-neutral migration package instead of loose CSVs') + .option('--org-strategy ', 'Org mapping for package mode: user-pool (default), connection, or none', 'user-pool') .option('--access-key-id ', 'AWS Access Key ID (uses default credential chain if omitted)') .option('--secret-access-key ', 'AWS Secret Access Key') .option('--session-token ', 'AWS Session Token') @@ -15,6 +17,7 @@ export function registerExportCognitoCommand(program) { .option('--saml-custom-entity-id-template ', 'Template for SAML custom Entity ID (default: urn:amazon:cognito:sp:{user_pool_id})') .option('--oidc-custom-redirect-uri-template ', 'Template for OIDC custom redirect URI') .option('--skip-external-provider-users', 'Skip federated Cognito users (userStatus=EXTERNAL_PROVIDER) — they will be JIT-provisioned by WorkOS on first SSO login') + .option('--quiet', 'Suppress progress output') .action(async (opts) => { try { const credentials = { @@ -39,6 +42,19 @@ export function registerExportCognitoCommand(program) { await client.authenticate(); console.log(chalk.green('Successfully authenticated with AWS')); const entities = opts.entities.split(',').map((e) => e.trim()); + if (opts.package) { + const packageEntities = opts.entities === 'connections,users' + ? ['users', 'organizations', 'memberships'] + : entities; + console.log(chalk.blue(`\nWriting Cognito migration package: ${packageEntities.join(', ')}`)); + await client.exportPackage({ + entities: packageEntities, + outputDir: opts.outputDir, + orgStrategy: parseOrgStrategy(opts.orgStrategy), + quiet: opts.quiet ?? false, + }); + return; + } console.log(chalk.blue(`\nExporting entities: ${entities.join(', ')}`)); const result = await client.exportEntities(entities); console.log(chalk.green('\nExport complete')); @@ -52,3 +68,10 @@ export function registerExportCognitoCommand(program) { } }); } +function parseOrgStrategy(value) { + const normalized = (value ?? 'user-pool').trim(); + if (normalized === 'user-pool' || normalized === 'connection' || normalized === 'none') { + return normalized; + } + throw new Error(`--org-strategy must be one of: user-pool, connection, none. Got "${value}"`); +} diff --git a/dist/exporters/auth0/exporter.js b/dist/exporters/auth0/exporter.js index 9007fa2..f146c61 100644 --- a/dist/exporters/auth0/exporter.js +++ b/dist/exporters/auth0/exporter.js @@ -45,9 +45,6 @@ export async function exportAuth0CsvWithClient(client, options) { } const startTime = Date.now(); const warnings = []; - let totalUsers = 0; - let totalOrgs = 0; - let skippedUsers = 0; // Open output streams const output = options.output; const writeStream = createWriteStream(output, { encoding: 'utf-8' }); @@ -56,18 +53,10 @@ export async function exportAuth0CsvWithClient(client, options) { try { // Write CSV header writeStream.write(CSV_COLUMNS.join(',') + '\n'); - if (options.useMetadata) { - const stats = await exportUsersWithMetadata(client, writeStream, skippedStream, options, warnings); - totalUsers = stats.totalUsers; - totalOrgs = stats.totalOrgs; - skippedUsers = stats.skippedUsers; - } - else { - const stats = await exportOrganizations(client, writeStream, skippedStream, options, warnings); - totalUsers = stats.totalUsers; - totalOrgs = stats.totalOrgs; - skippedUsers = stats.skippedUsers; - } + const stats = options.useMetadata + ? await exportUsersWithMetadata(client, writeStream, skippedStream, options, warnings) + : await exportOrganizations(client, writeStream, skippedStream, options, warnings); + const { totalUsers, totalOrgs, skippedUsers } = stats; await closeStream(writeStream); await closeStream(skippedStream); const duration = Date.now() - startTime; @@ -91,11 +80,10 @@ export async function exportAuth0CsvWithClient(client, options) { } async function exportOrganizations(client, writeStream, skippedStream, options, warnings) { let totalUsers = 0; - let totalOrgs = 0; let skippedUsers = 0; // Fetch all organizations const organizations = await fetchAllOrganizations(client, options.pageSize); - totalOrgs = organizations.length; + const totalOrgs = organizations.length; if (!options.quiet) { logger.info(`Found ${totalOrgs} organizations`); } diff --git a/dist/import/org-cache.js b/dist/import/org-cache.js index 70573ed..ad0d641 100644 --- a/dist/import/org-cache.js +++ b/dist/import/org-cache.js @@ -110,7 +110,7 @@ export class OrgCache { } if (!resolvedOrgId) { throw new Error(`Organization with external_id "${orgExternalId}" reported as existing ` + - `but could not be retrieved after 3 retries. Original: ${errorMsg}`); + `but could not be retrieved after 3 retries. Original: ${errorMsg}`, { cause: err }); } } else { diff --git a/dist/providers/cognito/client.d.ts b/dist/providers/cognito/client.d.ts index f84771a..a57836e 100644 --- a/dist/providers/cognito/client.d.ts +++ b/dist/providers/cognito/client.d.ts @@ -1,5 +1,6 @@ import type { ProviderClient, EntityType, ExportResult, ProviderCredentials } from '../../shared/types.js'; import { type ProxyTemplates } from './workos-csv.js'; +import { type CognitoOrgStrategy, type ExportCognitoPackageResult } from './package-exporter.js'; export interface CognitoClientOptions { /** Comma-separated pool IDs or a single ID. Overrides the USER_POOL_IDS credential. */ userPoolIds?: string[]; @@ -24,6 +25,12 @@ export declare class CognitoClient implements ProviderClient { validateCredentials(): Promise; getScopes(): string[]; getAvailableEntities(): Promise; + exportPackage(options?: { + entities?: string[]; + outputDir?: string; + orgStrategy?: CognitoOrgStrategy; + quiet?: boolean; + }): Promise; exportEntities(entityTypes: string[]): Promise; private exportConnections; private exportUsers; diff --git a/dist/providers/cognito/client.js b/dist/providers/cognito/client.js index 9d3cb35..e1df9ac 100644 --- a/dist/providers/cognito/client.js +++ b/dist/providers/cognito/client.js @@ -3,6 +3,7 @@ import path from 'node:path'; import chalk from 'chalk'; import { CognitoIdentityProviderClient, ListIdentityProvidersCommand, DescribeIdentityProviderCommand, ListUserPoolsCommand, ListUsersCommand, } from '@aws-sdk/client-cognito-identity-provider'; import { isSaml, isOidc, isFederatedUser, toSamlRow, toOidcRow, toUserRow, toCustomAttrRows, rowsToCsv, SAML_HEADERS, OIDC_HEADERS, CUSTOM_ATTR_HEADERS, USER_HEADERS, DEFAULT_SAML_CUSTOM_ENTITY_ID_TEMPLATE, } from './workos-csv.js'; +import { exportCognitoPackage, } from './package-exporter.js'; function countDuplicates(values) { const seen = new Set(); let dupes = 0; @@ -64,6 +65,47 @@ export class CognitoClient { }, ]; } + async exportPackage(options = {}) { + if (!this.client) + throw new Error('call authenticate() before exportPackage()'); + const requested = options.entities ?? ['users', 'organizations', 'memberships']; + const wantUsers = requested.includes('users'); + const wantSso = requested.includes('sso'); + const poolIds = this.resolvePoolIds(); + if (poolIds.length === 0) { + throw new Error('no user pool IDs provided — set COGNITO_USER_POOL_IDS, pass --user-pool-ids, or save to config'); + } + const providers = []; + const users = []; + for (const poolId of poolIds) { + if (wantSso || requested.includes('organizations') || requested.includes('memberships')) { + const fetched = await this.fetchProviders(poolId); + providers.push(...fetched); + } + if (wantUsers || requested.includes('memberships') || requested.includes('organizations')) { + const fetched = await this.fetchUsers(poolId); + users.push(...fetched); + } + } + return exportCognitoPackage({ providers, users }, { + outputDir: options.outputDir ?? this.options.outDir ?? process.cwd(), + entities: requested, + orgStrategy: options.orgStrategy ?? 'user-pool', + skipExternalProviderUsers: this.options.skipExternalProviderUsers ?? true, + proxy: { + samlCustomEntityId: this.options.proxy?.samlCustomEntityId ?? + process.env.SAML_CUSTOM_ENTITY_ID_TEMPLATE ?? + DEFAULT_SAML_CUSTOM_ENTITY_ID_TEMPLATE, + samlCustomAcsUrl: this.options.proxy?.samlCustomAcsUrl ?? + process.env.SAML_CUSTOM_ACS_URL_TEMPLATE ?? + null, + oidcCustomRedirectUri: this.options.proxy?.oidcCustomRedirectUri ?? + process.env.OIDC_CUSTOM_REDIRECT_URI_TEMPLATE ?? + null, + }, + quiet: options.quiet, + }); + } async exportEntities(entityTypes) { if (!this.client) throw new Error('call authenticate() before exportEntities()'); diff --git a/dist/providers/cognito/package-exporter.d.ts b/dist/providers/cognito/package-exporter.d.ts new file mode 100644 index 0000000..d2c6891 --- /dev/null +++ b/dist/providers/cognito/package-exporter.d.ts @@ -0,0 +1,56 @@ +import { type CognitoProvider, type CognitoUser, type ProxyTemplates } from './workos-csv.js'; +export type CognitoOrgStrategy = 'user-pool' | 'connection' | 'none'; +export interface CognitoPackageExportOptions { + outputDir: string; + entities?: string[]; + /** Strategy for mapping Cognito users to WorkOS organizations. Default: user-pool. */ + orgStrategy?: CognitoOrgStrategy; + /** Skip federated (EXTERNAL_PROVIDER) users. Default: true (will be JIT-provisioned by WorkOS). */ + skipExternalProviderUsers?: boolean; + /** Proxy template overrides. */ + proxy?: ProxyTemplates; + /** Suppress progress output. */ + quiet?: boolean; +} +export interface CognitoPackageInputs { + /** Identity providers fetched from each user pool. Optional when only exporting users. */ + providers?: CognitoProvider[]; + /** Cognito users fetched from each user pool. Optional when only exporting SSO. */ + users?: CognitoUser[]; +} +export interface CognitoPackageWarning { + timestamp: string; + code: string; + message: string; + user_pool_id?: string; + provider_name?: string; + email?: string; + details?: Record; +} +export interface CognitoPackageSkipped { + timestamp: string; + user_pool_id: string; + username: string; + email?: string; + reason: string; +} +export interface CognitoPackageStats { + totalUsers: number; + totalOrgs: number; + totalMemberships: number; + samlConnections: number; + oidcConnections: number; + customAttributeMappings: number; + proxyRoutes: number; + uploadUsers: number; + uploadOrganizations: number; + uploadMemberships: number; + skippedUsers: number; + warnings: CognitoPackageWarning[]; + skipped: CognitoPackageSkipped[]; +} +export interface ExportCognitoPackageResult { + outputDir: string; + stats: CognitoPackageStats; +} +export declare function exportCognitoPackage(inputs: CognitoPackageInputs, options: CognitoPackageExportOptions): Promise; diff --git a/dist/providers/cognito/package-exporter.js b/dist/providers/cognito/package-exporter.js new file mode 100644 index 0000000..4b12fda --- /dev/null +++ b/dist/providers/cognito/package-exporter.js @@ -0,0 +1,407 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { MIGRATION_PACKAGE_CSV_HEADERS, createMigrationPackageManifest, } from '../../package/manifest.js'; +import { createEmptyPackageFiles, getPackageFilePath, writeMigrationPackageManifest, writePackageJsonlRecords, } from '../../package/writer.js'; +import { packageMembershipToUploadMembershipRow, packageOrganizationToUploadOrganizationRow, packageUserToUploadUserRow, } from '../../package/upload.js'; +import { createCSVWriter } from '../../shared/csv-utils.js'; +import * as logger from '../../shared/logger.js'; +import { writeCustomAttributeMappingsCsv, writeOidcConnectionsCsv, writeProxyRoutesCsv, writeSamlConnectionsCsv, createProxyRouteRow, } from '../../sso/handoff.js'; +import { buildCustomAttributesJson, importedId as cognitoImportedId, isFederatedUser, isOidc, isSaml, toOidcRow, toSamlRow, toCustomAttrRows, toUserRow, } from './workos-csv.js'; +const USER_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.users; +const ORG_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.organizations; +const MEMBERSHIP_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.memberships; +const UPLOAD_USER_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadUsers; +const UPLOAD_ORG_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadOrganizations; +const UPLOAD_MEMBERSHIP_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadMemberships; +const DEFAULT_PACKAGE_ENTITIES = ['users', 'organizations', 'memberships']; +const SUPPORTED_PACKAGE_ENTITIES = new Set([...DEFAULT_PACKAGE_ENTITIES, 'sso']); +export async function exportCognitoPackage(inputs, options) { + const resolvedOutputDir = path.resolve(options.outputDir); + const requested = normalizeRequestedEntities(options.entities); + const wantUsers = requested.includes('users'); + const wantOrgs = requested.includes('organizations'); + const wantMemberships = requested.includes('memberships'); + const wantSso = requested.includes('sso'); + await createEmptyPackageFiles(resolvedOutputDir, buildHandoffNotes({ includeSso: wantSso })); + if (!options.quiet) { + logger.info(`Writing Cognito migration package to ${resolvedOutputDir}`); + } + const stats = createEmptyStats(); + const orgStrategy = options.orgStrategy ?? 'user-pool'; + if (wantOrgs || wantUsers || wantMemberships) { + await writeIdentityEntities({ + inputs, + stats, + outputDir: resolvedOutputDir, + orgStrategy, + skipExternalProviderUsers: options.skipExternalProviderUsers ?? true, + writeOrgs: wantOrgs, + writeUsers: wantUsers, + writeMemberships: wantMemberships, + }); + } + if (wantSso) { + await writeSsoEntities({ + providers: inputs.providers ?? [], + stats, + outputDir: resolvedOutputDir, + proxy: options.proxy ?? {}, + }); + } + await writePackageJsonlRecords(resolvedOutputDir, 'warnings', stats.warnings); + await writePackageJsonlRecords(resolvedOutputDir, 'skippedUsers', stats.skipped); + const manifest = createMigrationPackageManifest({ + provider: 'cognito', + generatedAt: new Date(), + entitiesRequested: requested, + entitiesExported: { + users: stats.totalUsers, + organizations: stats.totalOrgs, + memberships: stats.totalMemberships, + samlConnections: stats.samlConnections, + oidcConnections: stats.oidcConnections, + customAttributeMappings: stats.customAttributeMappings, + proxyRoutes: stats.proxyRoutes, + uploadUsers: stats.uploadUsers, + uploadOrganizations: stats.uploadOrganizations, + uploadMemberships: stats.uploadMemberships, + warnings: stats.warnings.length, + skippedUsers: stats.skipped.length, + }, + secretsRedacted: true, + secretRedaction: buildSecretRedactionMetadata(wantSso), + warnings: stats.warnings.map((warning) => warning.message), + }); + await writeMigrationPackageManifest(resolvedOutputDir, manifest); + if (!options.quiet) { + logger.success('\nPackage export complete'); + logger.info(` Organizations: ${stats.totalOrgs}`); + logger.info(` Memberships: ${stats.totalMemberships}`); + logger.info(` Users: ${stats.totalUsers}`); + if (wantSso) { + logger.info(` SAML connections: ${stats.samlConnections}`); + logger.info(` OIDC connections: ${stats.oidcConnections}`); + } + if (stats.skipped.length > 0) + logger.warn(` Skipped users: ${stats.skipped.length}`); + if (stats.warnings.length > 0) + logger.warn(` Warnings: ${stats.warnings.length}`); + } + return { outputDir: resolvedOutputDir, stats }; +} +async function writeIdentityEntities(ctx) { + const orgWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'organizations'), [ + ...ORG_HEADERS, + ]); + const userWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'users'), [...USER_HEADERS]); + const membershipWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'memberships'), [ + ...MEMBERSHIP_HEADERS, + ]); + const uploadUserWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'uploadUsers'), [ + ...UPLOAD_USER_HEADERS, + ]); + const uploadOrgWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'uploadOrganizations'), [...UPLOAD_ORG_HEADERS]); + const uploadMembershipWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'uploadMemberships'), [...UPLOAD_MEMBERSHIP_HEADERS]); + const seenUserIds = new Set(); + const seenOrgIds = new Set(); + const seenMembershipIds = new Set(); + try { + const orgRows = computeOrgRows(ctx); + if (ctx.writeOrgs) { + for (const row of orgRows) { + orgWriter.write(row); + ctx.stats.totalOrgs++; + const upload = packageOrganizationToUploadOrganizationRow(row); + if (upload && !seenOrgIds.has(upload.organization_id)) { + seenOrgIds.add(upload.organization_id); + uploadOrgWriter.write(upload); + ctx.stats.uploadOrganizations++; + } + } + } + if (ctx.writeUsers || ctx.writeMemberships) { + const orgByPoolId = new Map(orgRows.map((row) => [row.org_external_id, row])); + const users = ctx.inputs.users ?? []; + for (const user of users) { + if (!user.attributes.email) { + ctx.stats.skipped.push({ + timestamp: new Date().toISOString(), + user_pool_id: user.userPoolId, + username: user.username, + reason: 'no_email', + }); + ctx.stats.skippedUsers++; + continue; + } + if (ctx.skipExternalProviderUsers && isFederatedUser(user)) { + ctx.stats.skipped.push({ + timestamp: new Date().toISOString(), + user_pool_id: user.userPoolId, + username: user.username, + email: user.attributes.email, + reason: 'federated_user', + }); + ctx.stats.skippedUsers++; + continue; + } + const orgRow = orgByPoolId.get(user.userPoolId); + const userRowPartial = toUserRow(user); + const fullRow = { + email: userRowPartial.email, + password: '', + password_hash: '', + password_hash_type: '', + first_name: userRowPartial.first_name, + last_name: userRowPartial.last_name, + email_verified: userRowPartial.email_verified, + external_id: userRowPartial.external_id, + metadata: '', + org_id: '', + org_external_id: orgRow?.org_external_id ?? '', + org_name: orgRow?.org_name ?? '', + role_slugs: '', + }; + if (ctx.writeUsers) { + userWriter.write(fullRow); + ctx.stats.totalUsers++; + const uploadUser = packageUserToUploadUserRow(fullRow); + if (uploadUser && !seenUserIds.has(uploadUser.user_id)) { + seenUserIds.add(uploadUser.user_id); + uploadUserWriter.write(uploadUser); + ctx.stats.uploadUsers++; + } + } + if (ctx.writeMemberships && orgRow) { + const membershipRow = { + email: fullRow.email, + external_id: fullRow.external_id, + user_id: '', + org_id: '', + org_external_id: orgRow.org_external_id, + org_name: orgRow.org_name, + role_slugs: '', + metadata: '', + }; + membershipWriter.write(membershipRow); + ctx.stats.totalMemberships++; + const uploadMembership = packageMembershipToUploadMembershipRow(membershipRow); + if (uploadMembership) { + const key = `${uploadMembership.organization_id}:${uploadMembership.user_id}`; + if (!seenMembershipIds.has(key)) { + seenMembershipIds.add(key); + uploadMembershipWriter.write(uploadMembership); + ctx.stats.uploadMemberships++; + } + } + } + else if (ctx.writeMemberships && !orgRow) { + addWarning(ctx.stats, { + code: 'membership_missing_org', + message: `User ${user.username} in pool ${user.userPoolId} has no matching organization row; membership skipped.`, + user_pool_id: user.userPoolId, + email: user.attributes.email, + }); + } + } + } + } + finally { + await Promise.all([ + orgWriter.end(), + userWriter.end(), + membershipWriter.end(), + uploadUserWriter.end(), + uploadOrgWriter.end(), + uploadMembershipWriter.end(), + ]); + } +} +function computeOrgRows(ctx) { + if (ctx.orgStrategy === 'none') + return []; + if (ctx.orgStrategy === 'user-pool') { + const poolIds = new Set(); + for (const user of ctx.inputs.users ?? []) + poolIds.add(user.userPoolId); + for (const provider of ctx.inputs.providers ?? []) + poolIds.add(provider.userPoolId); + return [...poolIds].sort().map((poolId) => ({ + org_id: '', + org_external_id: poolId, + org_name: poolId, + domains: '', + metadata: '', + })); + } + // connection strategy: one org per provider; users cannot be auto-mapped to memberships. + const seen = new Set(); + const rows = []; + for (const provider of ctx.inputs.providers ?? []) { + const id = cognitoImportedId(provider); + if (seen.has(id)) + continue; + seen.add(id); + rows.push({ + org_id: '', + org_external_id: id, + org_name: provider.providerName, + domains: '', + metadata: '', + }); + } + if (ctx.inputs.users && ctx.inputs.users.length > 0) { + addWarning(ctx.stats, { + code: 'connection_strategy_no_memberships', + message: 'Connection-based org strategy cannot infer per-user memberships from Cognito. Users were exported without membership rows.', + }); + } + return rows; +} +async function writeSsoEntities(ctx) { + const samlRows = []; + const oidcRows = []; + const customAttrRows = []; + const proxyRows = []; + for (const provider of ctx.providers) { + if (isSaml(provider)) { + samlRows.push(toSamlRow(provider, ctx.proxy)); + proxyRows.push(buildProxyRoute(provider, ctx.proxy)); + } + else if (isOidc(provider)) { + oidcRows.push(toOidcRow(provider, ctx.proxy)); + proxyRows.push(buildProxyRoute(provider, ctx.proxy)); + } + else { + addWarning(ctx.stats, { + code: 'unsupported_connection_protocol', + message: `Cognito identity provider ${provider.providerName} (${provider.providerType}) is not SAML/OIDC; skipped.`, + provider_name: provider.providerName, + user_pool_id: provider.userPoolId, + }); + continue; + } + customAttrRows.push(...toCustomAttrRows(provider)); + if (Object.keys(provider.providerDetails).some((key) => key.toLowerCase().includes('secret'))) { + addWarning(ctx.stats, { + code: 'secrets_redacted', + message: `Connection ${cognitoImportedId(provider)} contained secret material; package keeps ${buildCustomAttributesJson(provider.attributeMapping) ? 'attribute mappings only' : 'redacted output only'}.`, + provider_name: provider.providerName, + user_pool_id: provider.userPoolId, + }); + } + } + await Promise.all([ + writeSamlConnectionsCsv(getPackageFilePath(ctx.outputDir, 'samlConnections'), samlRows), + writeOidcConnectionsCsv(getPackageFilePath(ctx.outputDir, 'oidcConnections'), oidcRows), + writeCustomAttributeMappingsCsv(getPackageFilePath(ctx.outputDir, 'customAttributeMappings'), customAttrRows), + writeProxyRoutesCsv(getPackageFilePath(ctx.outputDir, 'proxyRoutes'), proxyRows), + writeRawCognitoProviders(ctx.outputDir, ctx.providers), + ]); + ctx.stats.samlConnections = samlRows.length; + ctx.stats.oidcConnections = oidcRows.length; + ctx.stats.customAttributeMappings = customAttrRows.length; + ctx.stats.proxyRoutes = proxyRows.length; +} +function buildProxyRoute(provider, proxy) { + const protocol = isSaml(provider) ? 'saml' : 'oidc'; + return createProxyRouteRow({ + importedId: cognitoImportedId(provider), + organizationExternalId: provider.providerName, + provider: 'cognito', + protocol, + sourceAcsUrl: '', + sourceEntityId: provider.providerDetails.EntityId ?? '', + sourceRedirectUri: provider.providerDetails.SSORedirectBindingURI ?? '', + customAcsUrl: renderProxyTemplate(proxy.samlCustomAcsUrl ?? null, provider), + customEntityId: renderProxyTemplate(proxy.samlCustomEntityId ?? null, provider), + customRedirectUri: renderProxyTemplate(proxy.oidcCustomRedirectUri ?? null, provider), + cutoverState: 'legacy', + notes: '', + }); +} +function renderProxyTemplate(template, p) { + if (!template) + return ''; + return template + .replace(/\{provider_name\}/g, p.providerName) + .replace(/\{user_pool_id\}/g, p.userPoolId) + .replace(/\{region\}/g, p.region); +} +async function writeRawCognitoProviders(outputDir, providers) { + const rawDir = path.join(outputDir, 'raw'); + await fs.mkdir(rawDir, { recursive: true }); + const lines = providers.map((p) => JSON.stringify(p)).join('\n'); + await fs.writeFile(path.join(rawDir, 'cognito-providers.jsonl'), lines ? `${lines}\n` : '', 'utf-8'); +} +function createEmptyStats() { + return { + totalUsers: 0, + totalOrgs: 0, + totalMemberships: 0, + samlConnections: 0, + oidcConnections: 0, + customAttributeMappings: 0, + proxyRoutes: 0, + uploadUsers: 0, + uploadOrganizations: 0, + uploadMemberships: 0, + skippedUsers: 0, + warnings: [], + skipped: [], + }; +} +function addWarning(stats, warning) { + stats.warnings.push({ timestamp: new Date().toISOString(), ...warning }); +} +function normalizeRequestedEntities(entities) { + const requested = entities && entities.length > 0 + ? entities.flatMap((entity) => entity.split(',')) + : [...DEFAULT_PACKAGE_ENTITIES]; + const normalized = [ + ...new Set(requested.map((entity) => entity.trim().toLowerCase()).filter((entity) => entity.length > 0)), + ]; + if (normalized.includes('all')) + return [...SUPPORTED_PACKAGE_ENTITIES]; + for (const entity of normalized) { + if (!SUPPORTED_PACKAGE_ENTITIES.has(entity)) { + throw new Error(`Unsupported Cognito package entity "${entity}". Supported entities: ${[ + ...SUPPORTED_PACKAGE_ENTITIES, + ].join(', ')}`); + } + } + return normalized.length > 0 ? normalized : [...DEFAULT_PACKAGE_ENTITIES]; +} +function buildHandoffNotes(input) { + if (!input.includeSso) { + return [ + '# Cognito SSO handoff notes', + '', + 'This package was generated without Cognito SSO connection handoff files.', + 'Re-run with --entities sso (or include sso in a comma-separated entity list) when SSO handoff is needed.', + '', + ].join('\n'); + } + return [ + '# Cognito SSO handoff notes', + '', + 'Cognito SSO export is handoff-only. SAML and OIDC identity providers are mapped onto WorkOS connection CSVs but no WorkOS connections are created automatically.', + 'Use the proxy templates and proxy_routes.csv when staging a callback proxy during enterprise-connection cutover.', + '', + ].join('\n'); +} +function buildSecretRedactionMetadata(includeSso) { + if (!includeSso) { + return { + mode: 'not-applicable', + redacted: true, + notes: ['Cognito package mode does not export user passwords or connection secrets.'], + }; + } + return { + mode: 'redacted', + redacted: true, + redactedFields: ['client_secret'], + files: ['raw/cognito-providers.jsonl', 'sso/oidc_connections.csv'], + notes: ['Cognito does not export OIDC client secrets through DescribeIdentityProvider.'], + }; +} diff --git a/dist/roles/api-client.js b/dist/roles/api-client.js index 847d861..110a165 100644 --- a/dist/roles/api-client.js +++ b/dist/roles/api-client.js @@ -14,7 +14,6 @@ export async function listRolesForOrganization(organizationId) { const apiKey = getApiKey(); const roles = []; let after; - // eslint-disable-next-line no-constant-condition while (true) { const params = new URLSearchParams({ limit: '100' }); if (after) diff --git a/src/cli/commands/export-cognito.ts b/src/cli/commands/export-cognito.ts index fc7c421..8f4f0a5 100644 --- a/src/cli/commands/export-cognito.ts +++ b/src/cli/commands/export-cognito.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import chalk from 'chalk'; import { CognitoClient, type CognitoClientOptions } from '../../providers/cognito/index.js'; import type { ProviderCredentials } from '../../shared/types.js'; +import type { CognitoOrgStrategy } from '../../providers/cognito/package-exporter.js'; export function registerExportCognitoCommand(program: Command): void { program @@ -15,10 +16,16 @@ export function registerExportCognitoCommand(program: Command): void { ) .option( '--entities ', - 'Comma-separated entities to export (connections,users)', + 'Comma-separated entities (loose mode: connections,users; package mode: users,organizations,memberships,sso)', 'connections,users', ) .option('--output-dir ', 'Output directory for CSV files', '.') + .option('--package', 'Write a provider-neutral migration package instead of loose CSVs') + .option( + '--org-strategy ', + 'Org mapping for package mode: user-pool (default), connection, or none', + 'user-pool', + ) .option('--access-key-id ', 'AWS Access Key ID (uses default credential chain if omitted)') .option('--secret-access-key ', 'AWS Secret Access Key') .option('--session-token ', 'AWS Session Token') @@ -35,6 +42,7 @@ export function registerExportCognitoCommand(program: Command): void { '--skip-external-provider-users', 'Skip federated Cognito users (userStatus=EXTERNAL_PROVIDER) — they will be JIT-provisioned by WorkOS on first SSO login', ) + .option('--quiet', 'Suppress progress output') .action(async (opts) => { try { const credentials: ProviderCredentials = { @@ -63,8 +71,25 @@ export function registerExportCognitoCommand(program: Command): void { console.log(chalk.green('Successfully authenticated with AWS')); const entities = opts.entities.split(',').map((e: string) => e.trim()); - console.log(chalk.blue(`\nExporting entities: ${entities.join(', ')}`)); + if (opts.package) { + const packageEntities = + opts.entities === 'connections,users' + ? ['users', 'organizations', 'memberships'] + : entities; + console.log( + chalk.blue(`\nWriting Cognito migration package: ${packageEntities.join(', ')}`), + ); + await client.exportPackage({ + entities: packageEntities, + outputDir: opts.outputDir, + orgStrategy: parseOrgStrategy(opts.orgStrategy), + quiet: opts.quiet ?? false, + }); + return; + } + + console.log(chalk.blue(`\nExporting entities: ${entities.join(', ')}`)); const result = await client.exportEntities(entities); console.log(chalk.green('\nExport complete')); @@ -77,3 +102,11 @@ export function registerExportCognitoCommand(program: Command): void { } }); } + +function parseOrgStrategy(value: string | undefined): CognitoOrgStrategy { + const normalized = (value ?? 'user-pool').trim(); + if (normalized === 'user-pool' || normalized === 'connection' || normalized === 'none') { + return normalized; + } + throw new Error(`--org-strategy must be one of: user-pool, connection, none. Got "${value}"`); +} diff --git a/src/providers/cognito/__tests__/package-exporter.test.ts b/src/providers/cognito/__tests__/package-exporter.test.ts new file mode 100644 index 0000000..6666580 --- /dev/null +++ b/src/providers/cognito/__tests__/package-exporter.test.ts @@ -0,0 +1,203 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { streamCSV } from '../../../shared/csv-utils'; +import { validateMigrationPackage } from '../../../package/validator'; +import { exportCognitoPackage } from '../package-exporter'; +import type { CognitoProvider, CognitoUser } from '../workos-csv'; + +describe('exportCognitoPackage', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'workos-cognito-pkg-test-')); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + const acmeUser: CognitoUser = { + userPoolId: 'us-east-1_acme', + username: 'cognito-uuid-1', + attributes: { + sub: 'cognito-uuid-1', + email: 'alice@acme.com', + email_verified: 'true', + given_name: 'Alice', + family_name: 'Builder', + }, + userStatus: 'CONFIRMED', + enabled: true, + }; + + const externalUser: CognitoUser = { + userPoolId: 'us-east-1_acme', + username: 'cognito-uuid-2', + attributes: { + sub: 'cognito-uuid-2', + email: 'jit@acme.com', + }, + userStatus: 'EXTERNAL_PROVIDER', + enabled: true, + }; + + const samlProvider: CognitoProvider = { + userPoolId: 'us-east-1_acme', + providerName: 'AcmeOkta', + providerType: 'SAML', + region: 'us-east-1', + providerDetails: { + EntityId: 'https://idp.example.com/entity', + SSORedirectBindingURI: 'https://idp.example.com/sso', + }, + attributeMapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + given_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname', + family_name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname', + 'custom:department': 'department', + }, + idpIdentifiers: [], + }; + + const oidcProvider: CognitoProvider = { + userPoolId: 'us-east-1_acme', + providerName: 'AcmeAzure', + providerType: 'OIDC', + region: 'us-east-1', + providerDetails: { + client_id: 'azure_client', + oidc_issuer: 'https://login.microsoftonline.com/tenant', + }, + attributeMapping: { + email: 'email', + }, + idpIdentifiers: [], + }; + + it('writes a valid package with users, orgs, memberships, and skips federated users by default', async () => { + const result = await exportCognitoPackage( + { users: [acmeUser, externalUser], providers: [] }, + { outputDir: tempRoot, quiet: true }, + ); + + expect(result.stats.totalUsers).toBe(1); + expect(result.stats.totalOrgs).toBe(1); + expect(result.stats.totalMemberships).toBe(1); + expect(result.stats.skippedUsers).toBe(1); + + const validation = await validateMigrationPackage(tempRoot); + expect(validation.valid).toBe(true); + expect(validation.manifest?.provider).toBe('cognito'); + expect(validation.manifest?.entitiesExported).toMatchObject({ + users: 1, + organizations: 1, + memberships: 1, + uploadUsers: 1, + uploadOrganizations: 1, + uploadMemberships: 1, + skippedUsers: 1, + }); + + const users = await readCsv(path.join(tempRoot, 'users.csv')); + expect(users).toMatchObject([ + { + email: 'alice@acme.com', + external_id: 'cognito-uuid-1', + org_external_id: 'us-east-1_acme', + first_name: 'Alice', + last_name: 'Builder', + }, + ]); + + const orgs = await readCsv(path.join(tempRoot, 'organizations.csv')); + expect(orgs).toMatchObject([{ org_external_id: 'us-east-1_acme', org_name: 'us-east-1_acme' }]); + + const upload = await readCsv(path.join(tempRoot, 'workos_upload', 'users.csv')); + expect(upload).toMatchObject([ + { + user_id: 'cognito-uuid-1', + email: 'alice@acme.com', + password_hash: '', + }, + ]); + + const skipped = readJsonl(path.join(tempRoot, 'skipped_users.jsonl')); + expect(skipped).toMatchObject([{ username: 'cognito-uuid-2', reason: 'federated_user' }]); + }); + + it('writes SAML/OIDC handoff files and proxy routes when sso entity is requested', async () => { + const result = await exportCognitoPackage( + { providers: [samlProvider, oidcProvider], users: [] }, + { + outputDir: tempRoot, + entities: ['sso'], + quiet: true, + }, + ); + + expect(result.stats.samlConnections).toBe(1); + expect(result.stats.oidcConnections).toBe(1); + expect(result.stats.proxyRoutes).toBe(2); + + const validation = await validateMigrationPackage(tempRoot); + expect(validation.valid).toBe(true); + + const samlRows = await readCsv(path.join(tempRoot, 'sso', 'saml_connections.csv')); + expect(samlRows).toMatchObject([ + { + organizationName: 'AcmeOkta', + organizationExternalId: 'AcmeOkta', + idpEntityId: 'https://idp.example.com/entity', + idpUrl: 'https://idp.example.com/sso', + }, + ]); + + const oidcRows = await readCsv(path.join(tempRoot, 'sso', 'oidc_connections.csv')); + expect(oidcRows).toMatchObject([ + { + organizationExternalId: 'AcmeAzure', + clientId: 'azure_client', + }, + ]); + + const customAttr = await readCsv(path.join(tempRoot, 'sso', 'custom_attribute_mappings.csv')); + expect( + customAttr.some( + (row) => row.userPoolAttribute === 'custom:department' && row.idpClaim === 'department', + ), + ).toBe(true); + }); + + it('warns and writes header-only memberships when org strategy is connection', async () => { + const result = await exportCognitoPackage( + { providers: [samlProvider], users: [acmeUser] }, + { + outputDir: tempRoot, + orgStrategy: 'connection', + quiet: true, + }, + ); + + expect(result.stats.totalOrgs).toBe(1); + // memberships rows depend on matching pool→org row; with connection strategy there is none + expect(result.stats.totalMemberships).toBe(0); + + const warnings = readJsonl(path.join(tempRoot, 'warnings.jsonl')); + expect(warnings.some((w) => w.code === 'connection_strategy_no_memberships')).toBe(true); + }); +}); + +async function readCsv(filePath: string): Promise[]> { + const rows: Record[] = []; + for await (const row of streamCSV(filePath)) { + rows.push(row as Record); + } + return rows; +} + +function readJsonl(filePath: string): Array> { + const raw = fs.readFileSync(filePath, 'utf-8').trim(); + if (!raw) return []; + return raw.split(/\r?\n/).map((line) => JSON.parse(line) as Record); +} diff --git a/src/providers/cognito/client.ts b/src/providers/cognito/client.ts index f275b04..cb22272 100644 --- a/src/providers/cognito/client.ts +++ b/src/providers/cognito/client.ts @@ -34,6 +34,11 @@ import { USER_HEADERS, DEFAULT_SAML_CUSTOM_ENTITY_ID_TEMPLATE, } from './workos-csv.js'; +import { + exportCognitoPackage, + type CognitoOrgStrategy, + type ExportCognitoPackageResult, +} from './package-exporter.js'; function countDuplicates(values: string[]): number { const seen = new Set(); @@ -120,6 +125,65 @@ export class CognitoClient implements ProviderClient { ]; } + async exportPackage( + options: { + entities?: string[]; + outputDir?: string; + orgStrategy?: CognitoOrgStrategy; + quiet?: boolean; + } = {}, + ): Promise { + if (!this.client) throw new Error('call authenticate() before exportPackage()'); + + const requested = options.entities ?? ['users', 'organizations', 'memberships']; + const wantUsers = requested.includes('users'); + const wantSso = requested.includes('sso'); + const poolIds = this.resolvePoolIds(); + if (poolIds.length === 0) { + throw new Error( + 'no user pool IDs provided — set COGNITO_USER_POOL_IDS, pass --user-pool-ids, or save to config', + ); + } + + const providers: CognitoProvider[] = []; + const users: CognitoUser[] = []; + for (const poolId of poolIds) { + if (wantSso || requested.includes('organizations') || requested.includes('memberships')) { + const fetched = await this.fetchProviders(poolId); + providers.push(...fetched); + } + if (wantUsers || requested.includes('memberships') || requested.includes('organizations')) { + const fetched = await this.fetchUsers(poolId); + users.push(...fetched); + } + } + + return exportCognitoPackage( + { providers, users }, + { + outputDir: options.outputDir ?? this.options.outDir ?? process.cwd(), + entities: requested, + orgStrategy: options.orgStrategy ?? 'user-pool', + skipExternalProviderUsers: this.options.skipExternalProviderUsers ?? true, + proxy: { + samlCustomEntityId: + this.options.proxy?.samlCustomEntityId ?? + process.env.SAML_CUSTOM_ENTITY_ID_TEMPLATE ?? + DEFAULT_SAML_CUSTOM_ENTITY_ID_TEMPLATE, + samlCustomAcsUrl: + this.options.proxy?.samlCustomAcsUrl ?? + process.env.SAML_CUSTOM_ACS_URL_TEMPLATE ?? + null, + oidcCustomRedirectUri: + this.options.proxy?.oidcCustomRedirectUri ?? + process.env.OIDC_CUSTOM_REDIRECT_URI_TEMPLATE ?? + null, + }, + quiet: options.quiet, + }, + ); + } + async exportEntities(entityTypes: string[]): Promise { if (!this.client) throw new Error('call authenticate() before exportEntities()'); diff --git a/src/providers/cognito/package-exporter.ts b/src/providers/cognito/package-exporter.ts new file mode 100644 index 0000000..5044d69 --- /dev/null +++ b/src/providers/cognito/package-exporter.ts @@ -0,0 +1,579 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + MIGRATION_PACKAGE_CSV_HEADERS, + createMigrationPackageManifest, + type SecretRedactionMetadata, +} from '../../package/manifest.js'; +import { + createEmptyPackageFiles, + getPackageFilePath, + writeMigrationPackageManifest, + writePackageJsonlRecords, +} from '../../package/writer.js'; +import { + packageMembershipToUploadMembershipRow, + packageOrganizationToUploadOrganizationRow, + packageUserToUploadUserRow, +} from '../../package/upload.js'; +import { createCSVWriter } from '../../shared/csv-utils.js'; +import * as logger from '../../shared/logger.js'; +import { + writeCustomAttributeMappingsCsv, + writeOidcConnectionsCsv, + writeProxyRoutesCsv, + writeSamlConnectionsCsv, + createProxyRouteRow, + type CustomAttrRow, + type OidcRow, + type ProxyRouteRow, + type SamlRow, +} from '../../sso/handoff.js'; +import { + buildCustomAttributesJson, + importedId as cognitoImportedId, + isFederatedUser, + isOidc, + isSaml, + toOidcRow, + toSamlRow, + toCustomAttrRows, + toUserRow, + type CognitoProvider, + type CognitoUser, + type ProxyTemplates, +} from './workos-csv.js'; + +export type CognitoOrgStrategy = 'user-pool' | 'connection' | 'none'; + +export interface CognitoPackageExportOptions { + outputDir: string; + entities?: string[]; + /** Strategy for mapping Cognito users to WorkOS organizations. Default: user-pool. */ + orgStrategy?: CognitoOrgStrategy; + /** Skip federated (EXTERNAL_PROVIDER) users. Default: true (will be JIT-provisioned by WorkOS). */ + skipExternalProviderUsers?: boolean; + /** Proxy template overrides. */ + proxy?: ProxyTemplates; + /** Suppress progress output. */ + quiet?: boolean; +} + +export interface CognitoPackageInputs { + /** Identity providers fetched from each user pool. Optional when only exporting users. */ + providers?: CognitoProvider[]; + /** Cognito users fetched from each user pool. Optional when only exporting SSO. */ + users?: CognitoUser[]; +} + +export interface CognitoPackageWarning { + timestamp: string; + code: string; + message: string; + user_pool_id?: string; + provider_name?: string; + email?: string; + details?: Record; +} + +export interface CognitoPackageSkipped { + timestamp: string; + user_pool_id: string; + username: string; + email?: string; + reason: string; +} + +export interface CognitoPackageStats { + totalUsers: number; + totalOrgs: number; + totalMemberships: number; + samlConnections: number; + oidcConnections: number; + customAttributeMappings: number; + proxyRoutes: number; + uploadUsers: number; + uploadOrganizations: number; + uploadMemberships: number; + skippedUsers: number; + warnings: CognitoPackageWarning[]; + skipped: CognitoPackageSkipped[]; +} + +const USER_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.users; +const ORG_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.organizations; +const MEMBERSHIP_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.memberships; +const UPLOAD_USER_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadUsers; +const UPLOAD_ORG_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadOrganizations; +const UPLOAD_MEMBERSHIP_HEADERS = MIGRATION_PACKAGE_CSV_HEADERS.uploadMemberships; + +const DEFAULT_PACKAGE_ENTITIES = ['users', 'organizations', 'memberships'] as const; +const SUPPORTED_PACKAGE_ENTITIES = new Set([...DEFAULT_PACKAGE_ENTITIES, 'sso']); + +export interface ExportCognitoPackageResult { + outputDir: string; + stats: CognitoPackageStats; +} + +export async function exportCognitoPackage( + inputs: CognitoPackageInputs, + options: CognitoPackageExportOptions, +): Promise { + const resolvedOutputDir = path.resolve(options.outputDir); + const requested = normalizeRequestedEntities(options.entities); + const wantUsers = requested.includes('users'); + const wantOrgs = requested.includes('organizations'); + const wantMemberships = requested.includes('memberships'); + const wantSso = requested.includes('sso'); + + await createEmptyPackageFiles(resolvedOutputDir, buildHandoffNotes({ includeSso: wantSso })); + + if (!options.quiet) { + logger.info(`Writing Cognito migration package to ${resolvedOutputDir}`); + } + + const stats = createEmptyStats(); + const orgStrategy: CognitoOrgStrategy = options.orgStrategy ?? 'user-pool'; + + if (wantOrgs || wantUsers || wantMemberships) { + await writeIdentityEntities({ + inputs, + stats, + outputDir: resolvedOutputDir, + orgStrategy, + skipExternalProviderUsers: options.skipExternalProviderUsers ?? true, + writeOrgs: wantOrgs, + writeUsers: wantUsers, + writeMemberships: wantMemberships, + }); + } + + if (wantSso) { + await writeSsoEntities({ + providers: inputs.providers ?? [], + stats, + outputDir: resolvedOutputDir, + proxy: options.proxy ?? {}, + }); + } + + await writePackageJsonlRecords(resolvedOutputDir, 'warnings', stats.warnings); + await writePackageJsonlRecords(resolvedOutputDir, 'skippedUsers', stats.skipped); + + const manifest = createMigrationPackageManifest({ + provider: 'cognito', + generatedAt: new Date(), + entitiesRequested: requested, + entitiesExported: { + users: stats.totalUsers, + organizations: stats.totalOrgs, + memberships: stats.totalMemberships, + samlConnections: stats.samlConnections, + oidcConnections: stats.oidcConnections, + customAttributeMappings: stats.customAttributeMappings, + proxyRoutes: stats.proxyRoutes, + uploadUsers: stats.uploadUsers, + uploadOrganizations: stats.uploadOrganizations, + uploadMemberships: stats.uploadMemberships, + warnings: stats.warnings.length, + skippedUsers: stats.skipped.length, + }, + secretsRedacted: true, + secretRedaction: buildSecretRedactionMetadata(wantSso), + warnings: stats.warnings.map((warning) => warning.message), + }); + + await writeMigrationPackageManifest(resolvedOutputDir, manifest); + + if (!options.quiet) { + logger.success('\nPackage export complete'); + logger.info(` Organizations: ${stats.totalOrgs}`); + logger.info(` Memberships: ${stats.totalMemberships}`); + logger.info(` Users: ${stats.totalUsers}`); + if (wantSso) { + logger.info(` SAML connections: ${stats.samlConnections}`); + logger.info(` OIDC connections: ${stats.oidcConnections}`); + } + if (stats.skipped.length > 0) logger.warn(` Skipped users: ${stats.skipped.length}`); + if (stats.warnings.length > 0) logger.warn(` Warnings: ${stats.warnings.length}`); + } + + return { outputDir: resolvedOutputDir, stats }; +} + +interface IdentityWriteContext { + inputs: CognitoPackageInputs; + stats: CognitoPackageStats; + outputDir: string; + orgStrategy: CognitoOrgStrategy; + skipExternalProviderUsers: boolean; + writeOrgs: boolean; + writeUsers: boolean; + writeMemberships: boolean; +} + +async function writeIdentityEntities(ctx: IdentityWriteContext): Promise { + const orgWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'organizations'), [ + ...ORG_HEADERS, + ]); + const userWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'users'), [...USER_HEADERS]); + const membershipWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'memberships'), [ + ...MEMBERSHIP_HEADERS, + ]); + const uploadUserWriter = createCSVWriter(getPackageFilePath(ctx.outputDir, 'uploadUsers'), [ + ...UPLOAD_USER_HEADERS, + ]); + const uploadOrgWriter = createCSVWriter( + getPackageFilePath(ctx.outputDir, 'uploadOrganizations'), + [...UPLOAD_ORG_HEADERS], + ); + const uploadMembershipWriter = createCSVWriter( + getPackageFilePath(ctx.outputDir, 'uploadMemberships'), + [...UPLOAD_MEMBERSHIP_HEADERS], + ); + + const seenUserIds = new Set(); + const seenOrgIds = new Set(); + const seenMembershipIds = new Set(); + + try { + const orgRows = computeOrgRows(ctx); + if (ctx.writeOrgs) { + for (const row of orgRows) { + orgWriter.write(row); + ctx.stats.totalOrgs++; + const upload = packageOrganizationToUploadOrganizationRow(row); + if (upload && !seenOrgIds.has(upload.organization_id)) { + seenOrgIds.add(upload.organization_id); + uploadOrgWriter.write(upload); + ctx.stats.uploadOrganizations++; + } + } + } + + if (ctx.writeUsers || ctx.writeMemberships) { + const orgByPoolId = new Map(orgRows.map((row) => [row.org_external_id, row])); + const users = ctx.inputs.users ?? []; + + for (const user of users) { + if (!user.attributes.email) { + ctx.stats.skipped.push({ + timestamp: new Date().toISOString(), + user_pool_id: user.userPoolId, + username: user.username, + reason: 'no_email', + }); + ctx.stats.skippedUsers++; + continue; + } + + if (ctx.skipExternalProviderUsers && isFederatedUser(user)) { + ctx.stats.skipped.push({ + timestamp: new Date().toISOString(), + user_pool_id: user.userPoolId, + username: user.username, + email: user.attributes.email, + reason: 'federated_user', + }); + ctx.stats.skippedUsers++; + continue; + } + + const orgRow = orgByPoolId.get(user.userPoolId); + const userRowPartial = toUserRow(user); + const fullRow = { + email: userRowPartial.email, + password: '', + password_hash: '', + password_hash_type: '', + first_name: userRowPartial.first_name, + last_name: userRowPartial.last_name, + email_verified: userRowPartial.email_verified, + external_id: userRowPartial.external_id, + metadata: '', + org_id: '', + org_external_id: orgRow?.org_external_id ?? '', + org_name: orgRow?.org_name ?? '', + role_slugs: '', + }; + + if (ctx.writeUsers) { + userWriter.write(fullRow); + ctx.stats.totalUsers++; + const uploadUser = packageUserToUploadUserRow(fullRow); + if (uploadUser && !seenUserIds.has(uploadUser.user_id)) { + seenUserIds.add(uploadUser.user_id); + uploadUserWriter.write(uploadUser); + ctx.stats.uploadUsers++; + } + } + + if (ctx.writeMemberships && orgRow) { + const membershipRow = { + email: fullRow.email, + external_id: fullRow.external_id, + user_id: '', + org_id: '', + org_external_id: orgRow.org_external_id, + org_name: orgRow.org_name, + role_slugs: '', + metadata: '', + }; + membershipWriter.write(membershipRow); + ctx.stats.totalMemberships++; + const uploadMembership = packageMembershipToUploadMembershipRow(membershipRow); + if (uploadMembership) { + const key = `${uploadMembership.organization_id}:${uploadMembership.user_id}`; + if (!seenMembershipIds.has(key)) { + seenMembershipIds.add(key); + uploadMembershipWriter.write(uploadMembership); + ctx.stats.uploadMemberships++; + } + } + } else if (ctx.writeMemberships && !orgRow) { + addWarning(ctx.stats, { + code: 'membership_missing_org', + message: `User ${user.username} in pool ${user.userPoolId} has no matching organization row; membership skipped.`, + user_pool_id: user.userPoolId, + email: user.attributes.email, + }); + } + } + } + } finally { + await Promise.all([ + orgWriter.end(), + userWriter.end(), + membershipWriter.end(), + uploadUserWriter.end(), + uploadOrgWriter.end(), + uploadMembershipWriter.end(), + ]); + } +} + +function computeOrgRows(ctx: IdentityWriteContext): Array> { + if (ctx.orgStrategy === 'none') return []; + + if (ctx.orgStrategy === 'user-pool') { + const poolIds = new Set(); + for (const user of ctx.inputs.users ?? []) poolIds.add(user.userPoolId); + for (const provider of ctx.inputs.providers ?? []) poolIds.add(provider.userPoolId); + return [...poolIds].sort().map((poolId) => ({ + org_id: '', + org_external_id: poolId, + org_name: poolId, + domains: '', + metadata: '', + })); + } + + // connection strategy: one org per provider; users cannot be auto-mapped to memberships. + const seen = new Set(); + const rows: Array> = []; + for (const provider of ctx.inputs.providers ?? []) { + const id = cognitoImportedId(provider); + if (seen.has(id)) continue; + seen.add(id); + rows.push({ + org_id: '', + org_external_id: id, + org_name: provider.providerName, + domains: '', + metadata: '', + }); + } + if (ctx.inputs.users && ctx.inputs.users.length > 0) { + addWarning(ctx.stats, { + code: 'connection_strategy_no_memberships', + message: + 'Connection-based org strategy cannot infer per-user memberships from Cognito. Users were exported without membership rows.', + }); + } + return rows; +} + +interface SsoWriteContext { + providers: CognitoProvider[]; + stats: CognitoPackageStats; + outputDir: string; + proxy: ProxyTemplates; +} + +async function writeSsoEntities(ctx: SsoWriteContext): Promise { + const samlRows: SamlRow[] = []; + const oidcRows: OidcRow[] = []; + const customAttrRows: CustomAttrRow[] = []; + const proxyRows: ProxyRouteRow[] = []; + + for (const provider of ctx.providers) { + if (isSaml(provider)) { + samlRows.push(toSamlRow(provider, ctx.proxy)); + proxyRows.push(buildProxyRoute(provider, ctx.proxy)); + } else if (isOidc(provider)) { + oidcRows.push(toOidcRow(provider, ctx.proxy)); + proxyRows.push(buildProxyRoute(provider, ctx.proxy)); + } else { + addWarning(ctx.stats, { + code: 'unsupported_connection_protocol', + message: `Cognito identity provider ${provider.providerName} (${provider.providerType}) is not SAML/OIDC; skipped.`, + provider_name: provider.providerName, + user_pool_id: provider.userPoolId, + }); + continue; + } + customAttrRows.push(...toCustomAttrRows(provider)); + + if (Object.keys(provider.providerDetails).some((key) => key.toLowerCase().includes('secret'))) { + addWarning(ctx.stats, { + code: 'secrets_redacted', + message: `Connection ${cognitoImportedId(provider)} contained secret material; package keeps ${buildCustomAttributesJson(provider.attributeMapping) ? 'attribute mappings only' : 'redacted output only'}.`, + provider_name: provider.providerName, + user_pool_id: provider.userPoolId, + }); + } + } + + await Promise.all([ + writeSamlConnectionsCsv(getPackageFilePath(ctx.outputDir, 'samlConnections'), samlRows), + writeOidcConnectionsCsv(getPackageFilePath(ctx.outputDir, 'oidcConnections'), oidcRows), + writeCustomAttributeMappingsCsv( + getPackageFilePath(ctx.outputDir, 'customAttributeMappings'), + customAttrRows, + ), + writeProxyRoutesCsv(getPackageFilePath(ctx.outputDir, 'proxyRoutes'), proxyRows), + writeRawCognitoProviders(ctx.outputDir, ctx.providers), + ]); + + ctx.stats.samlConnections = samlRows.length; + ctx.stats.oidcConnections = oidcRows.length; + ctx.stats.customAttributeMappings = customAttrRows.length; + ctx.stats.proxyRoutes = proxyRows.length; +} + +function buildProxyRoute(provider: CognitoProvider, proxy: ProxyTemplates): ProxyRouteRow { + const protocol = isSaml(provider) ? 'saml' : 'oidc'; + return createProxyRouteRow({ + importedId: cognitoImportedId(provider), + organizationExternalId: provider.providerName, + provider: 'cognito', + protocol, + sourceAcsUrl: '', + sourceEntityId: provider.providerDetails.EntityId ?? '', + sourceRedirectUri: provider.providerDetails.SSORedirectBindingURI ?? '', + customAcsUrl: renderProxyTemplate(proxy.samlCustomAcsUrl ?? null, provider), + customEntityId: renderProxyTemplate(proxy.samlCustomEntityId ?? null, provider), + customRedirectUri: renderProxyTemplate(proxy.oidcCustomRedirectUri ?? null, provider), + cutoverState: 'legacy', + notes: '', + }); +} + +function renderProxyTemplate(template: string | null | undefined, p: CognitoProvider): string { + if (!template) return ''; + return template + .replace(/\{provider_name\}/g, p.providerName) + .replace(/\{user_pool_id\}/g, p.userPoolId) + .replace(/\{region\}/g, p.region); +} + +async function writeRawCognitoProviders( + outputDir: string, + providers: CognitoProvider[], +): Promise { + const rawDir = path.join(outputDir, 'raw'); + await fs.mkdir(rawDir, { recursive: true }); + const lines = providers.map((p) => JSON.stringify(p)).join('\n'); + await fs.writeFile( + path.join(rawDir, 'cognito-providers.jsonl'), + lines ? `${lines}\n` : '', + 'utf-8', + ); +} + +function createEmptyStats(): CognitoPackageStats { + return { + totalUsers: 0, + totalOrgs: 0, + totalMemberships: 0, + samlConnections: 0, + oidcConnections: 0, + customAttributeMappings: 0, + proxyRoutes: 0, + uploadUsers: 0, + uploadOrganizations: 0, + uploadMemberships: 0, + skippedUsers: 0, + warnings: [], + skipped: [], + }; +} + +function addWarning( + stats: CognitoPackageStats, + warning: Omit, +): void { + stats.warnings.push({ timestamp: new Date().toISOString(), ...warning }); +} + +function normalizeRequestedEntities(entities: string[] | undefined): string[] { + const requested = + entities && entities.length > 0 + ? entities.flatMap((entity) => entity.split(',')) + : [...DEFAULT_PACKAGE_ENTITIES]; + const normalized = [ + ...new Set( + requested.map((entity) => entity.trim().toLowerCase()).filter((entity) => entity.length > 0), + ), + ]; + + if (normalized.includes('all')) return [...SUPPORTED_PACKAGE_ENTITIES]; + + for (const entity of normalized) { + if (!SUPPORTED_PACKAGE_ENTITIES.has(entity)) { + throw new Error( + `Unsupported Cognito package entity "${entity}". Supported entities: ${[ + ...SUPPORTED_PACKAGE_ENTITIES, + ].join(', ')}`, + ); + } + } + + return normalized.length > 0 ? normalized : [...DEFAULT_PACKAGE_ENTITIES]; +} + +function buildHandoffNotes(input: { includeSso: boolean }): string { + if (!input.includeSso) { + return [ + '# Cognito SSO handoff notes', + '', + 'This package was generated without Cognito SSO connection handoff files.', + 'Re-run with --entities sso (or include sso in a comma-separated entity list) when SSO handoff is needed.', + '', + ].join('\n'); + } + return [ + '# Cognito SSO handoff notes', + '', + 'Cognito SSO export is handoff-only. SAML and OIDC identity providers are mapped onto WorkOS connection CSVs but no WorkOS connections are created automatically.', + 'Use the proxy templates and proxy_routes.csv when staging a callback proxy during enterprise-connection cutover.', + '', + ].join('\n'); +} + +function buildSecretRedactionMetadata(includeSso: boolean): SecretRedactionMetadata { + if (!includeSso) { + return { + mode: 'not-applicable', + redacted: true, + notes: ['Cognito package mode does not export user passwords or connection secrets.'], + }; + } + return { + mode: 'redacted', + redacted: true, + redactedFields: ['client_secret'], + files: ['raw/cognito-providers.jsonl', 'sso/oidc_connections.csv'], + notes: ['Cognito does not export OIDC client secrets through DescribeIdentityProvider.'], + }; +}