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
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -320,13 +340,16 @@ workos-migrate export-cognito \

Options:

- `--entities <list>` - Comma-separated entities to export: `connections`, `users` (default: both)
- `--package` - Write a migration package (recommended). Without this flag, loose CSVs are written.
- `--entities <list>` - Loose mode: `connections,users`. Package mode: `users,organizations,memberships,sso`.
- `--org-strategy <strategy>` - Package mode only: `user-pool` (default), `connection`, or `none`.
- `--output-dir <dir>` - Output directory for CSV files (default: current directory)
- `--saml-custom-entity-id-template <url>` - Template for SAML custom Entity ID (default: `urn:amazon:cognito:sp:{user_pool_id}`)
- `--saml-custom-acs-url-template <url>` - Template for SAML custom ACS URL (placeholders: `{provider_name}`, `{user_pool_id}`, `{region}`)
- `--oidc-custom-redirect-uri-template <url>` - 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
Expand Down
25 changes: 24 additions & 1 deletion dist/cli/commands/export-cognito.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ export function registerExportCognitoCommand(program) {
.description('Export users and SSO connections from AWS Cognito user pools')
.requiredOption('--region <region>', 'AWS region (e.g. us-east-1)', process.env.AWS_REGION)
.requiredOption('--user-pool-ids <ids>', 'Comma-separated Cognito user pool IDs', process.env.COGNITO_USER_POOL_IDS)
.option('--entities <entities>', 'Comma-separated entities to export (connections,users)', 'connections,users')
.option('--entities <entities>', 'Comma-separated entities (loose mode: connections,users; package mode: users,organizations,memberships,sso)', 'connections,users')
.option('--output-dir <dir>', 'Output directory for CSV files', '.')
.option('--package', 'Write a provider-neutral migration package instead of loose CSVs')
.option('--org-strategy <strategy>', 'Org mapping for package mode: user-pool (default), connection, or none', 'user-pool')
.option('--access-key-id <id>', 'AWS Access Key ID (uses default credential chain if omitted)')
.option('--secret-access-key <secret>', 'AWS Secret Access Key')
.option('--session-token <token>', 'AWS Session Token')
.option('--saml-custom-acs-url-template <url>', 'Template for SAML custom ACS URL (placeholders: {provider_name}, {user_pool_id}, {region})')
.option('--saml-custom-entity-id-template <url>', 'Template for SAML custom Entity ID (default: urn:amazon:cognito:sp:{user_pool_id})')
.option('--oidc-custom-redirect-uri-template <url>', '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 = {
Expand All @@ -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'));
Expand All @@ -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}"`);
}
22 changes: 5 additions & 17 deletions dist/exporters/auth0/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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;
Expand All @@ -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`);
}
Expand Down
2 changes: 1 addition & 1 deletion dist/import/org-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions dist/providers/cognito/client.d.ts
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -24,6 +25,12 @@ export declare class CognitoClient implements ProviderClient {
validateCredentials(): Promise<void>;
getScopes(): string[];
getAvailableEntities(): Promise<EntityType[]>;
exportPackage(options?: {
entities?: string[];
outputDir?: string;
orgStrategy?: CognitoOrgStrategy;
quiet?: boolean;
}): Promise<ExportCognitoPackageResult>;
exportEntities(entityTypes: string[]): Promise<ExportResult>;
private exportConnections;
private exportUsers;
Expand Down
42 changes: 42 additions & 0 deletions dist/providers/cognito/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()');
Expand Down
56 changes: 56 additions & 0 deletions dist/providers/cognito/package-exporter.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}
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<ExportCognitoPackageResult>;
Loading