From 6b40ba9152c3212307f790023a1db80d945f57f0 Mon Sep 17 00:00:00 2001 From: Zac Burrage Date: Fri, 1 May 2026 17:21:26 -0500 Subject: [PATCH 1/2] feat: add auth0 sso handoff export --- README.md | 17 + dist/cli/commands/export-auth0.js | 12 + dist/exporters/auth0/package-exporter.d.ts | 5 +- dist/exporters/auth0/package-exporter.js | 337 +++++++- dist/exporters/auth0/sso-mapper.d.ts | 35 + dist/exporters/auth0/sso-mapper.js | 544 +++++++++++++ dist/shared/types.d.ts | 2 + dist/sso/handoff.d.ts | 10 +- dist/sso/handoff.js | 14 + proxy-sample-auth0/README.md | 93 +++ proxy-sample-auth0/worker.js | 75 ++ proxy-sample-auth0/wrangler.toml.example | 16 + src/cli/commands/export-auth0.ts | 16 + .../auth0/__tests__/package-exporter.test.ts | 220 +++++- .../auth0/__tests__/sso-mapper.test.ts | 242 ++++++ src/exporters/auth0/package-exporter.ts | 484 +++++++++++- src/exporters/auth0/sso-mapper.ts | 726 ++++++++++++++++++ src/shared/types.ts | 2 + src/sso/__tests__/handoff.test.ts | 17 + src/sso/handoff.ts | 25 +- 20 files changed, 2825 insertions(+), 67 deletions(-) create mode 100644 dist/exporters/auth0/sso-mapper.d.ts create mode 100644 dist/exporters/auth0/sso-mapper.js create mode 100644 proxy-sample-auth0/README.md create mode 100644 proxy-sample-auth0/worker.js create mode 100644 proxy-sample-auth0/wrangler.toml.example create mode 100644 src/exporters/auth0/__tests__/sso-mapper.test.ts create mode 100644 src/exporters/auth0/sso-mapper.ts diff --git a/README.md b/README.md index b85f9fd..3f985b5 100644 --- a/README.md +++ b/README.md @@ -119,16 +119,33 @@ workos-migrate export-auth0 \ --output-dir ./migration-auth0 ``` +To write only SSO handoff files: + +```bash +workos-migrate export-auth0 \ + --domain my-tenant.us.auth0.com \ + --client-id \ + --client-secret \ + --package \ + --entities sso \ + --output-dir ./migration-auth0-sso +``` + Options: - `--orgs ` - Filter to specific Auth0 organization IDs +- `--entities ` - Comma-separated package entities to export (`users,organizations,memberships,sso`) - `--rate-limit ` - API requests per second (default: 50) - `--use-metadata` - Use `user_metadata` for org discovery instead of the Organizations API - `--include-federated-users` - Include federated/JIT users in package mode (skipped by default) +- `--include-secrets` - Include SSO connection secrets in package handoff files (redacted by default) - `--job-id ` - Enable export checkpointing for large tenants - `--resume [jobId]` - Resume a previously checkpointed export The export maps Auth0 fields to WorkOS CSV format, including `email_verified`, `external_id`, and custom metadata. +Auth0 package SSO export is handoff-only: it emits only SAML and OIDC enterprise connections with enough configuration for WorkOS handoff. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings. + +For a callback proxy reference implementation during Auth0 enterprise-connection cutover, see [`proxy-sample-auth0`](proxy-sample-auth0/README.md). The repo also includes [`proxy-sample-cognito`](proxy-sample-cognito/README.md) for Cognito migrations. ### 3. Merge password hashes (optional) diff --git a/dist/cli/commands/export-auth0.js b/dist/cli/commands/export-auth0.js index 721e046..19035a0 100644 --- a/dist/cli/commands/export-auth0.js +++ b/dist/cli/commands/export-auth0.js @@ -10,6 +10,8 @@ export function registerExportAuth0Command(program) { .option('--output ', 'Output CSV file path') .option('--package', 'Write a provider-neutral migration package') .option('--output-dir ', 'Output directory for package mode') + .option('--entities ', 'Comma-separated package entities to export (users,organizations,memberships,sso)', 'users,organizations,memberships') + .option('--include-secrets', 'Include Auth0 SSO connection secrets in package handoff files') .option('--orgs ', 'Filter to specific Auth0 org IDs') .option('--page-size ', 'API pagination size (max 100)', '100') .option('--rate-limit ', 'API requests per second', '50') @@ -36,6 +38,8 @@ export function registerExportAuth0Command(program) { output: opts.output, package: opts.package ?? false, outputDir: opts.outputDir, + entities: parseEntities(opts.entities), + includeSecrets: opts.includeSecrets ?? false, orgs: opts.orgs, pageSize: parseInt(opts.pageSize, 10), rateLimit: parseInt(opts.rateLimit, 10), @@ -56,3 +60,11 @@ export function registerExportAuth0Command(program) { } }); } +function parseEntities(value) { + if (!value) + return undefined; + return value + .split(',') + .map((entity) => entity.trim()) + .filter(Boolean); +} diff --git a/dist/exporters/auth0/package-exporter.d.ts b/dist/exporters/auth0/package-exporter.d.ts index 579ec80..6e4b7a1 100644 --- a/dist/exporters/auth0/package-exporter.d.ts +++ b/dist/exporters/auth0/package-exporter.d.ts @@ -1,10 +1,13 @@ -import type { Auth0ExportOptions, Auth0Organization, Auth0User, ExportSummary } from '../../shared/types.js'; +import type { Auth0Connection, Auth0ExportOptions, Auth0Organization, Auth0OrganizationConnection, Auth0User, ExportSummary } from '../../shared/types.js'; export interface Auth0ExportClient { testConnection?(): Promise<{ success: boolean; error?: string; }>; + getConnections?(page?: number, perPage?: number, strategy?: string | string[]): Promise; + getConnection?(connectionId: string): Promise; getOrganizations(page?: number, perPage?: number): Promise; + getOrganizationConnections?(orgId: string, page?: number, perPage?: number): Promise; getOrganizationMembers(orgId: string, page?: number, perPage?: number): Promise>; diff --git a/dist/exporters/auth0/package-exporter.js b/dist/exporters/auth0/package-exporter.js index e20c40b..7c4c090 100644 --- a/dist/exporters/auth0/package-exporter.js +++ b/dist/exporters/auth0/package-exporter.js @@ -1,13 +1,18 @@ +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 { createCSVWriter } from '../../shared/csv-utils.js'; +import { writeCustomAttributeMappingsCsv, writeOidcConnectionsCsv, writeProxyRoutesCsv, writeSamlConnectionsCsv, } from '../../sso/handoff.js'; import * as logger from '../../shared/logger.js'; -import { Auth0Client } from './client.js'; +import { Auth0Client, isMissingConnectionOptionsScopeError } from './client.js'; import { extractOrgFromMetadata, isFederatedAuth0User, mapAuth0UserToWorkOS } from './mapper.js'; +import { AUTH0_REDACTED_SECRET_FIELDS, classifyAuth0ConnectionProtocol, mapAuth0ConnectionToSsoHandoff, redactAuth0ConnectionSecrets, } from './sso-mapper.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 DEFAULT_PACKAGE_ENTITIES = ['users', 'organizations', 'memberships']; +const SUPPORTED_PACKAGE_ENTITIES = new Set([...DEFAULT_PACKAGE_ENTITIES, 'sso']); export async function exportAuth0Package(options) { const client = new Auth0Client({ domain: options.domain, @@ -34,33 +39,47 @@ export async function exportAuth0PackageWithClient(client, options) { throw new Error('--output-dir is required when using --package'); } const resolvedOutputDir = path.resolve(outputDir); - await createEmptyPackageFiles(resolvedOutputDir, buildHandoffNotes()); + const requestedEntities = normalizeRequestedEntities(options.entities); + const shouldExportIdentityEntities = requestedEntities.some((entity) => DEFAULT_PACKAGE_ENTITIES.includes(entity)); + const shouldExportSso = requestedEntities.includes('sso'); + await createEmptyPackageFiles(resolvedOutputDir, buildHandoffNotes({ + includeSso: shouldExportSso, + includeSecrets: options.includeSecrets ?? false, + })); if (!options.quiet) { logger.info(`Writing Auth0 migration package to ${resolvedOutputDir}`); } - const stats = options.useMetadata - ? await exportPackageUsersWithMetadata(client, resolvedOutputDir, options) - : await exportPackageOrganizations(client, resolvedOutputDir, options); + const stats = createEmptyPackageStats(); + if (shouldExportIdentityEntities) { + const identityStats = options.useMetadata + ? await exportPackageUsersWithMetadata(client, resolvedOutputDir, options) + : await exportPackageOrganizations(client, resolvedOutputDir, options); + mergePackageStats(stats, identityStats); + } + if (shouldExportSso) { + const ssoStats = await exportPackageSso(client, resolvedOutputDir, options); + mergePackageStats(stats, ssoStats); + } await writePackageJsonlRecords(resolvedOutputDir, 'warnings', stats.warnings); await writePackageJsonlRecords(resolvedOutputDir, 'skippedUsers', stats.skipped); const manifest = createMigrationPackageManifest({ provider: 'auth0', sourceTenant: options.domain, generatedAt: new Date(), - entitiesRequested: ['users', 'organizations', 'memberships'], + entitiesRequested: requestedEntities, entitiesExported: { users: stats.totalUsers, organizations: stats.totalOrgs, memberships: stats.totalMemberships, + samlConnections: stats.samlConnections, + oidcConnections: stats.oidcConnections, + customAttributeMappings: stats.customAttributeMappings, + proxyRoutes: stats.proxyRoutes, warnings: stats.warnings.length, skippedUsers: stats.skipped.length, }, - secretsRedacted: true, - secretRedaction: { - mode: 'not-applicable', - redacted: true, - notes: ['Auth0 package core does not export connection secrets or password hashes.'], - }, + secretsRedacted: shouldExportSso ? !(options.includeSecrets ?? false) : true, + secretRedaction: buildSecretRedactionMetadata(shouldExportSso, options.includeSecrets ?? false), warnings: stats.warnings.map((warning) => warning.message), }); await writeMigrationPackageManifest(resolvedOutputDir, manifest); @@ -70,6 +89,11 @@ export async function exportAuth0PackageWithClient(client, options) { logger.info(` Organizations: ${stats.totalOrgs}`); logger.info(` Memberships: ${stats.totalMemberships}`); logger.info(` User rows exported: ${stats.totalUsers}`); + if (shouldExportSso) { + logger.info(` SAML connections: ${stats.samlConnections}`); + logger.info(` OIDC connections: ${stats.oidcConnections}`); + logger.info(` Proxy routes: ${stats.proxyRoutes}`); + } if (stats.skippedUsers > 0) { logger.warn(` Users skipped: ${stats.skippedUsers}`); } @@ -101,14 +125,8 @@ async function exportPackageOrganizations(client, outputDir, options) { const membershipWriter = createCSVWriter(getPackageFilePath(outputDir, 'memberships'), [ ...MEMBERSHIP_HEADERS, ]); - const stats = { - totalUsers: 0, - totalOrgs: organizations.length, - totalMemberships: 0, - skippedUsers: 0, - warnings: [], - skipped: [], - }; + const stats = createEmptyPackageStats(); + stats.totalOrgs = organizations.length; try { for (const org of organizations) { orgWriter.write(toOrganizationRow(org)); @@ -173,14 +191,7 @@ async function exportPackageUsersWithMetadata(client, outputDir, options) { const membershipWriter = createCSVWriter(getPackageFilePath(outputDir, 'memberships'), [ ...MEMBERSHIP_HEADERS, ]); - const stats = { - totalUsers: 0, - totalOrgs: 0, - totalMemberships: 0, - skippedUsers: 0, - warnings: [], - skipped: [], - }; + const stats = createEmptyPackageStats(); const seenOrgs = new Set(); if (!options.quiet) { logger.info('Using metadata-based org discovery'); @@ -225,6 +236,65 @@ async function exportPackageUsersWithMetadata(client, outputDir, options) { } return stats; } +async function exportPackageSso(client, outputDir, options) { + if (!client.getConnections) { + throw new Error('Auth0 SSO package export requires Management API connection support.'); + } + const stats = createEmptyPackageStats(); + let connections; + try { + connections = await fetchAllConnections(client, options.pageSize); + } + catch (error) { + if (!isMissingConnectionOptionsScopeError(error)) + throw error; + addWarning(stats, 'missing_connections_options_scope', 'Auth0 SSO connection export was skipped because the Management API token is missing read:connections_options.'); + await writeRawAuth0Connections(outputDir, [], options.includeSecrets ?? false); + return stats; + } + const organizations = await fetchOrganizationsForSso(client, options); + const orgBindingsByConnectionId = await fetchOrganizationConnectionBindings(client, organizations, options, stats); + const hydratedConnections = await hydrateSsoConnections(client, connections, stats); + const samlRows = []; + const oidcRows = []; + const customAttributeRows = []; + const proxyRouteRows = []; + const candidateConnections = hydratedConnections.filter((connection) => !options.orgs || orgBindingsByConnectionId.has(connection.id)); + for (const connection of candidateConnections) { + const mapping = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: options.domain, + orgBindings: orgBindingsByConnectionId.get(connection.id) ?? [], + includeSecrets: options.includeSecrets ?? false, + }); + for (const warning of mapping.warnings) { + addSsoWarning(stats, warning); + } + if (mapping.status !== 'mapped') + continue; + if (mapping.samlRow) + samlRows.push(mapping.samlRow); + if (mapping.oidcRow) + oidcRows.push(mapping.oidcRow); + customAttributeRows.push(...mapping.customAttributeRows); + proxyRouteRows.push(mapping.proxyRouteRow); + } + await Promise.all([ + writeSamlConnectionsCsv(getPackageFilePath(outputDir, 'samlConnections'), samlRows), + writeOidcConnectionsCsv(getPackageFilePath(outputDir, 'oidcConnections'), oidcRows), + writeCustomAttributeMappingsCsv(getPackageFilePath(outputDir, 'customAttributeMappings'), customAttributeRows), + writeProxyRoutesCsv(getPackageFilePath(outputDir, 'proxyRoutes'), proxyRouteRows), + writeRawAuth0Connections(outputDir, candidateConnections, options.includeSecrets ?? false), + ]); + stats.samlConnections = samlRows.length; + stats.oidcConnections = oidcRows.length; + stats.customAttributeMappings = customAttributeRows.length; + stats.proxyRoutes = proxyRouteRows.length; + if (!options.quiet) { + logger.info(`Exported ${stats.samlConnections + stats.oidcConnections} SSO handoff connection row(s)`); + } + return stats; +} function writePackageUserAndMembership(user, org, userWriter, membershipWriter, options, stats) { if (!user || !user.email) { addSkipped(stats, user?.user_id, user?.email, org, 'no_email'); @@ -262,6 +332,94 @@ async function fetchAllOrganizations(client, pageSize) { } return allOrgs; } +async function fetchOrganizationsForSso(client, options) { + try { + const organizations = await fetchAllOrganizations(client, options.pageSize); + return options.orgs + ? organizations.filter((organization) => options.orgs?.includes(organization.id)) + : organizations; + } + catch (error) { + if (!options.quiet) { + logger.warn(`Unable to fetch Auth0 organizations for SSO binding: ${error.message}`); + } + return []; + } +} +async function fetchAllConnections(client, pageSize) { + if (!client.getConnections) + return []; + const connections = []; + let page = 0; + let hasMore = true; + while (hasMore) { + const batch = await client.getConnections(page, pageSize); + if (batch.length === 0) + break; + connections.push(...batch); + hasMore = batch.length >= pageSize; + page++; + } + return connections; +} +async function fetchOrganizationConnectionBindings(client, organizations, options, stats) { + const bindings = new Map(); + if (!client.getOrganizationConnections || organizations.length === 0) + return bindings; + for (const organization of organizations) { + try { + let page = 0; + let hasMore = true; + while (hasMore) { + const orgConnections = await client.getOrganizationConnections(organization.id, page, options.pageSize); + if (orgConnections.length === 0) + break; + for (const organizationConnection of orgConnections) { + const connectionId = organizationConnection.connection_id || organizationConnection.connection?.id; + if (!connectionId) + continue; + const existing = bindings.get(connectionId) ?? []; + existing.push({ + organization, + organizationConnection, + }); + bindings.set(connectionId, existing); + } + hasMore = orgConnections.length >= options.pageSize; + page++; + } + } + catch (error) { + addWarning(stats, 'org_connection_fetch_failed', `Failed to fetch enabled Auth0 connections for org ${organization.id}: ${error.message}`, organization); + } + } + return bindings; +} +async function hydrateSsoConnections(client, connections, stats) { + if (!client.getConnection) + return connections; + const hydrated = []; + for (const connection of connections) { + const protocol = classifyAuth0ConnectionProtocol(connection); + if (protocol === 'unsupported' || hasReadableOptions(connection)) { + hydrated.push(connection); + continue; + } + try { + hydrated.push(await client.getConnection(connection.id)); + } + catch (error) { + if (!isMissingConnectionOptionsScopeError(error)) + throw error; + addWarning(stats, 'missing_connections_options_scope', `Auth0 connection ${connection.id} options could not be read because the token is missing read:connections_options.`, undefined, undefined, connection); + hydrated.push(connection); + } + } + return hydrated; +} +function hasReadableOptions(connection) { + return Boolean(connection.options && Object.keys(connection.options).length > 0); +} function toOrganizationRow(org) { return { org_id: '', @@ -299,6 +457,88 @@ function normalizeCsvRow(row, headers) { } return normalized; } +async function writeRawAuth0Connections(outputDir, connections, includeSecrets) { + const rawDir = path.join(outputDir, 'raw'); + await fs.mkdir(rawDir, { recursive: true }); + const records = connections.map((connection) => includeSecrets ? connection : redactAuth0ConnectionSecrets(connection)); + const contents = records.map((record) => JSON.stringify(record)).join('\n'); + await fs.writeFile(path.join(rawDir, 'auth0-connections.jsonl'), contents ? `${contents}\n` : '', 'utf-8'); +} +function createEmptyPackageStats() { + return { + totalUsers: 0, + totalOrgs: 0, + totalMemberships: 0, + samlConnections: 0, + oidcConnections: 0, + customAttributeMappings: 0, + proxyRoutes: 0, + skippedUsers: 0, + warnings: [], + skipped: [], + }; +} +function mergePackageStats(target, source) { + target.totalUsers += source.totalUsers; + target.totalOrgs += source.totalOrgs; + target.totalMemberships += source.totalMemberships; + target.samlConnections += source.samlConnections; + target.oidcConnections += source.oidcConnections; + target.customAttributeMappings += source.customAttributeMappings; + target.proxyRoutes += source.proxyRoutes; + target.skippedUsers += source.skippedUsers; + target.warnings.push(...source.warnings); + target.skipped.push(...source.skipped); +} +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 Auth0 package entity "${entity}". Supported entities: ${[ + ...SUPPORTED_PACKAGE_ENTITIES, + ].join(', ')}`); + } + } + return normalized.length > 0 ? normalized : [...DEFAULT_PACKAGE_ENTITIES]; +} +function buildSecretRedactionMetadata(includeSso, includeSecrets) { + if (!includeSso) { + return { + mode: 'not-applicable', + redacted: true, + notes: ['Auth0 package core does not export connection secrets or password hashes.'], + }; + } + if (includeSecrets) { + return { + mode: 'included', + redacted: false, + files: [ + 'raw/auth0-connections.jsonl', + 'sso/oidc_connections.csv', + 'sso/saml_connections.csv', + ], + notes: ['Auth0 SSO connection secrets were included because --include-secrets was set.'], + }; + } + return { + mode: 'redacted', + redacted: true, + redactedFields: [...AUTH0_REDACTED_SECRET_FIELDS], + files: ['raw/auth0-connections.jsonl', 'sso/oidc_connections.csv', 'sso/saml_connections.csv'], + notes: [ + 'Auth0 SSO connection secrets are redacted by default. Re-run with --include-secrets only when the output directory can safely store secrets.', + ], + }; +} function addSkipped(stats, userId, email, org, reason, error) { stats.skippedUsers++; stats.skipped.push({ @@ -311,15 +551,35 @@ function addSkipped(stats, userId, email, org, reason, error) { ...(error ? { error } : {}), }); } -function addWarning(stats, code, message, org, userId) { +function addWarning(stats, code, message, org, userId, connection, details) { stats.warnings.push({ timestamp: new Date().toISOString(), code, message, ...(org ? { org_id: org.id, org_name: org.display_name || org.name } : {}), ...(userId ? { user_id: userId } : {}), + ...(connection + ? { connection_id: connection.id, protocol: classifyAuth0ConnectionProtocol(connection) } + : {}), + ...(details ? { details } : {}), }); } +function addSsoWarning(stats, warning) { + stats.warnings.push({ + timestamp: new Date().toISOString(), + code: warning.code, + message: warning.message, + ...(warning.organizationExternalId ? { org_id: warning.organizationExternalId } : {}), + ...(warning.importedId + ? { connection_id: auth0ConnectionIdFromImportedId(warning.importedId) } + : {}), + ...(warning.protocol ? { protocol: warning.protocol } : {}), + ...(warning.details ? { details: warning.details } : {}), + }); +} +function auth0ConnectionIdFromImportedId(importedId) { + return importedId.startsWith('auth0:') ? importedId.slice('auth0:'.length) : importedId; +} function extractDomains(metadata) { const raw = metadata?.domains ?? metadata?.domain; if (!raw) @@ -334,12 +594,25 @@ function extractDomains(metadata) { } return []; } -function buildHandoffNotes() { +function buildHandoffNotes(input) { + if (!input.includeSso) { + return [ + '# Auth0 SSO handoff notes', + '', + 'This package was generated without Auth0 SSO connection handoff files.', + 'Run package mode with --entities sso, or include sso in a comma-separated entity list, when SSO handoff is needed.', + '', + ].join('\n'); + } return [ '# Auth0 SSO handoff notes', '', - 'This package was generated by Auth0 package core and does not include SAML/OIDC connection handoff files yet.', - 'Run the Auth0 SSO handoff export phase when connection export support is enabled.', + 'Auth0 SSO export is handoff-only. The package writes SAML and OIDC connection CSVs for WorkOS/manual processing and does not create WorkOS SSO connections automatically.', + 'Only Auth0 `samlp` and `oidc` enterprise connections with enough configuration are emitted. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings.', + 'If one Auth0 connection is enabled for multiple Auth0 organizations, the exporter writes one handoff row with the union of source organization domains and a confirmation warning.', + input.includeSecrets + ? 'Connection secrets were included because --include-secrets was set.' + : 'Connection secrets were redacted. Re-run with --include-secrets only if the output directory can safely store secrets.', '', ].join('\n'); } diff --git a/dist/exporters/auth0/sso-mapper.d.ts b/dist/exporters/auth0/sso-mapper.d.ts new file mode 100644 index 0000000..2108f9f --- /dev/null +++ b/dist/exporters/auth0/sso-mapper.d.ts @@ -0,0 +1,35 @@ +import type { Auth0Connection, Auth0Organization, Auth0OrganizationConnection } from '../../shared/types.js'; +import { type CustomAttrRow, type OidcRow, type ProxyRouteRow, type SamlRow, type SsoHandoffWarning } from '../../sso/handoff.js'; +export type Auth0SsoProtocol = 'saml' | 'oidc'; +export type Auth0SsoClassification = Auth0SsoProtocol | 'unsupported'; +export interface Auth0SsoConnectionOrgBinding { + organization: Auth0Organization; + organizationConnection?: Auth0OrganizationConnection; +} +export interface Auth0SsoMappingInput { + connection: Auth0Connection; + domain: string; + orgBindings?: Auth0SsoConnectionOrgBinding[]; + includeSecrets?: boolean; +} +export type Auth0SsoConnectionMapping = { + status: 'mapped'; + protocol: Auth0SsoProtocol; + importedId: string; + samlRow?: SamlRow; + oidcRow?: OidcRow; + customAttributeRows: CustomAttrRow[]; + proxyRouteRow: ProxyRouteRow; + warnings: SsoHandoffWarning[]; +} | { + status: 'skipped'; + protocol: Auth0SsoClassification; + importedId: string; + reason: string; + warnings: SsoHandoffWarning[]; +}; +export declare const AUTH0_REDACTED_SECRET_FIELDS: readonly ["client_secret", "clientSecret", "secret", "password", "private_key", "privateKey", "requestSigningKey", "assertionEncryptionKey", "nameIdEncryptionKey", "access_token", "refresh_token", "id_token"]; +export declare function classifyAuth0ConnectionProtocol(connection: Auth0Connection): Auth0SsoClassification; +export declare function buildAuth0ConnectionImportedId(connection: Auth0Connection): string; +export declare function mapAuth0ConnectionToSsoHandoff(input: Auth0SsoMappingInput): Auth0SsoConnectionMapping; +export declare function redactAuth0ConnectionSecrets(value: unknown): unknown; diff --git a/dist/exporters/auth0/sso-mapper.js b/dist/exporters/auth0/sso-mapper.js new file mode 100644 index 0000000..b0b3d95 --- /dev/null +++ b/dist/exporters/auth0/sso-mapper.js @@ -0,0 +1,544 @@ +import { createCustomAttributeMappingRow, createOidcConnectionRow, createProxyRouteRow, createSamlConnectionRow, incompleteConnectionConfigurationWarning, missingDomainsWarning, multiOrgConnectionConsolidationWarning, redactedSecretsWarning, unsupportedConnectionProtocolWarning, } from '../../sso/handoff.js'; +import { normalizeDiscoveryEndpoint, parseSamlMetadata } from '../../sso/saml-metadata.js'; +export const AUTH0_REDACTED_SECRET_FIELDS = [ + 'client_secret', + 'clientSecret', + 'secret', + 'password', + 'private_key', + 'privateKey', + 'requestSigningKey', + 'assertionEncryptionKey', + 'nameIdEncryptionKey', + 'access_token', + 'refresh_token', + 'id_token', +]; +const SAML_XML_OPTION_KEYS = [ + 'metadataXml', + 'metadataXML', + 'metadataFile', + 'metadata_file', + 'idpMetadataXml', + 'idp_metadata_xml', +]; +const SAML_METADATA_URL_KEYS = [ + 'metadataUrl', + 'metadataURL', + 'metadata_url', + 'idpMetadataUrl', + 'idp_metadata_url', + 'MetadataURL', +]; +const SAML_IDP_ENTITY_ID_KEYS = [ + 'idpEntityId', + 'idp_entity_id', + 'entityId', + 'entityID', + 'issuer', + 'idpIssuer', +]; +const SAML_IDP_URL_KEYS = [ + 'signInEndpoint', + 'signin_url', + 'signInUrl', + 'ssoUrl', + 'sso_url', + 'idpUrl', + 'idp_url', + 'SSORedirectBindingURI', +]; +const SAML_CERT_KEYS = [ + 'signingCert', + 'signing_cert', + 'x509Cert', + 'x509cert', + 'x509_certificate', + 'cert', + 'certificate', +]; +const SAML_SP_ENTITY_ID_KEYS = [ + 'audience', + 'spEntityId', + 'sp_entity_id', + 'serviceProviderEntityId', +]; +const SAML_ACS_URL_KEYS = [ + 'callbackUrl', + 'callbackURL', + 'acsUrl', + 'acs_url', + 'recipient', + 'destination', +]; +const SAML_SECRET_KEYS = [ + 'requestSigningKey', + 'request_signing_key', + 'assertionEncryptionKey', + 'assertion_encryption_key', + 'nameIdEncryptionKey', + 'name_id_encryption_key', +]; +const OIDC_CLIENT_ID_KEYS = ['client_id', 'clientId']; +const OIDC_CLIENT_SECRET_KEYS = ['client_secret', 'clientSecret']; +const OIDC_DISCOVERY_KEYS = [ + 'discoveryEndpoint', + 'discovery_endpoint', + 'discoveryUrl', + 'discovery_url', + 'issuer', + 'issuerUrl', + 'issuer_url', +]; +const OIDC_REDIRECT_URI_KEYS = [ + 'redirectUri', + 'redirect_uri', + 'callbackUrl', + 'callbackURL', +]; +const ATTRIBUTE_MAPPING_KEYS = [ + 'fieldsMap', + 'fieldMap', + 'fields_map', + 'mapping', + 'attributeMap', + 'attribute_map', + 'attributes', + 'profileMap', + 'profile_map', +]; +const COMMON_PROFILE_ATTRIBUTES = new Set([ + 'email', + 'given_name', + 'family_name', + 'first_name', + 'last_name', + 'name', + 'nickname', + 'picture', + 'user_id', + 'sub', +]); +const REDACTED_VALUE = '[REDACTED]'; +export function classifyAuth0ConnectionProtocol(connection) { + const strategy = connection.strategy.toLowerCase(); + if (strategy === 'samlp') + return 'saml'; + if (strategy === 'oidc') + return 'oidc'; + return 'unsupported'; +} +export function buildAuth0ConnectionImportedId(connection) { + return `auth0:${connection.id}`; +} +export function mapAuth0ConnectionToSsoHandoff(input) { + const { connection } = input; + const importedId = buildAuth0ConnectionImportedId(connection); + const protocol = classifyAuth0ConnectionProtocol(connection); + if (protocol === 'unsupported') { + const warning = unsupportedConnectionProtocolWarning({ + provider: 'auth0', + protocol: connection.strategy || 'unknown', + importedId, + strategy: connection.strategy, + reason: 'Only Auth0 samlp and oidc enterprise connections are supported for WorkOS SSO handoff.', + }); + return { + status: 'skipped', + protocol, + importedId, + reason: 'unsupported_connection_protocol', + warnings: [warning], + }; + } + if (protocol === 'saml') { + return mapSamlConnection(input, importedId); + } + return mapOidcConnection(input, importedId); +} +export function redactAuth0ConnectionSecrets(value) { + if (Array.isArray(value)) { + return value.map((item) => redactAuth0ConnectionSecrets(item)); + } + if (!isRecord(value)) { + return value; + } + const redacted = {}; + for (const [key, nestedValue] of Object.entries(value)) { + redacted[key] = shouldRedactKey(key) + ? REDACTED_VALUE + : redactAuth0ConnectionSecrets(nestedValue); + } + return redacted; +} +function mapSamlConnection(input, importedId) { + const { connection } = input; + const options = recordValue(connection.options); + const metadataXml = getFirstString(options, SAML_XML_OPTION_KEYS); + const parsedMetadata = parseSamlMetadata(metadataXml); + const idpMetadataUrl = getFirstString(options, SAML_METADATA_URL_KEYS); + const idpEntityId = firstNonEmpty(getFirstString(options, SAML_IDP_ENTITY_ID_KEYS), parsedMetadata.entityId); + const idpUrl = firstNonEmpty(getFirstString(options, SAML_IDP_URL_KEYS), parsedMetadata.ssoRedirectUrl); + const x509Cert = firstNonEmpty(getFirstString(options, SAML_CERT_KEYS), parsedMetadata.x509Cert); + const missingFields = missingSamlFields({ idpEntityId, idpUrl, x509Cert, idpMetadataUrl }); + if (missingFields.length > 0) { + const warning = incompleteConnectionConfigurationWarning({ + provider: 'auth0', + protocol: 'saml', + importedId, + strategy: connection.strategy, + missingFields, + reason: 'SAML handoff requires IdP metadata URL or the entity ID, SSO URL, and signing certificate.', + }); + return { + status: 'skipped', + protocol: 'saml', + importedId, + reason: 'incomplete_connection_configuration', + warnings: [warning], + }; + } + const organization = buildOrganizationContext(connection, input.orgBindings ?? [], 'saml', importedId); + const attributeMappings = extractAttributeMappings(connection); + const customAcsUrl = getFirstString(options, SAML_ACS_URL_KEYS); + const customEntityId = getFirstString(options, SAML_SP_ENTITY_ID_KEYS); + const sourceAcsUrl = customAcsUrl || buildAuth0CallbackUrl(input.domain, connection.name); + const samlSecretValues = getSecretValues(options, SAML_SECRET_KEYS); + const warnings = [...organization.warnings]; + if (!input.includeSecrets && samlSecretValues.length > 0) { + warnings.push(redactedSecretsWarning({ + provider: 'auth0', + protocol: 'saml', + importedId, + fields: samlSecretValues, + file: 'sso/saml_connections.csv', + })); + } + const row = createSamlConnectionRow({ + organizationName: organization.organizationName, + organizationExternalId: organization.organizationExternalId, + domains: organization.domains.join(','), + idpEntityId, + idpUrl, + x509Cert, + idpMetadataUrl, + customEntityId, + customAcsUrl, + idpIdAttribute: lookupMapping(attributeMappings, ['user_id', 'sub']), + emailAttribute: lookupMapping(attributeMappings, ['email']), + firstNameAttribute: lookupMapping(attributeMappings, ['given_name', 'first_name']), + lastNameAttribute: lookupMapping(attributeMappings, ['family_name', 'last_name']), + name: lookupMapping(attributeMappings, ['name']), + customAttributes: buildCustomAttributesJson(attributeMappings), + idpInitiatedEnabled: boolishString(getOptionValue(options, ['idpinitiated', 'idpInitiated'])), + requestSigningKey: input.includeSecrets + ? getFirstString(options, ['requestSigningKey', 'request_signing_key']) + : '', + assertionEncryptionKey: input.includeSecrets + ? getFirstString(options, ['assertionEncryptionKey', 'assertion_encryption_key']) + : '', + nameIdEncryptionKey: input.includeSecrets + ? getFirstString(options, ['nameIdEncryptionKey', 'name_id_encryption_key']) + : '', + importedId, + }); + return { + status: 'mapped', + protocol: 'saml', + importedId, + samlRow: row, + customAttributeRows: toCustomAttributeRows(attributeMappings, importedId, organization, 'SAML'), + proxyRouteRow: createProxyRouteRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + provider: 'auth0', + protocol: 'saml', + sourceAcsUrl, + sourceEntityId: customEntityId, + customAcsUrl, + customEntityId, + cutoverState: 'legacy', + notes: 'Existing Auth0 SAML ACS route should be proxied until the IdP is updated to WorkOS.', + }), + warnings, + }; +} +function mapOidcConnection(input, importedId) { + const { connection } = input; + const options = recordValue(connection.options); + const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); + const clientSecret = getFirstString(options, OIDC_CLIENT_SECRET_KEYS); + const discoveryEndpoint = normalizeDiscoveryEndpoint(getFirstString(options, OIDC_DISCOVERY_KEYS)); + const missingFields = ['clientId', 'discoveryEndpoint'].filter((field) => { + if (field === 'clientId') + return !clientId; + return !discoveryEndpoint; + }); + if (input.includeSecrets && !clientSecret) { + missingFields.push('clientSecret'); + } + if (missingFields.length > 0) { + const warning = incompleteConnectionConfigurationWarning({ + provider: 'auth0', + protocol: 'oidc', + importedId, + strategy: connection.strategy, + missingFields, + reason: 'OIDC handoff requires a client ID and discovery endpoint.', + }); + return { + status: 'skipped', + protocol: 'oidc', + importedId, + reason: 'incomplete_connection_configuration', + warnings: [warning], + }; + } + const organization = buildOrganizationContext(connection, input.orgBindings ?? [], 'oidc', importedId); + const attributeMappings = extractAttributeMappings(connection); + const customRedirectUri = getFirstString(options, OIDC_REDIRECT_URI_KEYS); + const sourceRedirectUri = customRedirectUri || buildAuth0CallbackUrl(input.domain, connection.name); + const warnings = [...organization.warnings]; + if (!input.includeSecrets && clientSecret) { + warnings.push(redactedSecretsWarning({ + provider: 'auth0', + protocol: 'oidc', + importedId, + fields: ['clientSecret'], + file: 'sso/oidc_connections.csv', + })); + } + const row = createOidcConnectionRow({ + organizationName: organization.organizationName, + organizationExternalId: organization.organizationExternalId, + domains: organization.domains.join(','), + clientId, + clientSecret: input.includeSecrets ? clientSecret : '', + discoveryEndpoint: discoveryEndpoint ?? '', + customRedirectUri, + name: lookupMapping(attributeMappings, ['name']), + customAttributes: buildCustomAttributesJson(attributeMappings), + importedId, + }); + return { + status: 'mapped', + protocol: 'oidc', + importedId, + oidcRow: row, + customAttributeRows: toCustomAttributeRows(attributeMappings, importedId, organization, 'OIDC'), + proxyRouteRow: createProxyRouteRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + provider: 'auth0', + protocol: 'oidc', + sourceRedirectUri, + customRedirectUri, + cutoverState: 'legacy', + notes: 'Existing Auth0 OIDC redirect route should be proxied until the IdP is updated to WorkOS.', + }), + warnings, + }; +} +function missingSamlFields(input) { + if (input.idpMetadataUrl) + return []; + const missing = []; + if (!input.idpEntityId) + missing.push('idpEntityId'); + if (!input.idpUrl) + missing.push('idpUrl'); + if (!input.x509Cert) + missing.push('x509Cert'); + return missing; +} +function buildOrganizationContext(connection, orgBindings, protocol, importedId) { + const warnings = []; + const connectionName = connection.display_name || connection.name; + if (orgBindings.length === 1) { + const org = orgBindings[0].organization; + const domains = extractDomains(org.metadata); + const context = { + organizationName: org.display_name || org.name, + organizationExternalId: org.id, + domains, + warnings, + }; + addMissingDomainWarning(context, protocol, importedId); + return context; + } + if (orgBindings.length > 1) { + const domains = uniqueDomains(orgBindings.flatMap((binding) => extractDomains(binding.organization.metadata))); + const organizationExternalId = connection.id; + const context = { + organizationName: connectionName, + organizationExternalId, + domains, + warnings, + }; + warnings.push(multiOrgConnectionConsolidationWarning({ + provider: 'auth0', + protocol, + importedId, + organizationExternalId, + sourceOrganizationIds: orgBindings.map((binding) => binding.organization.id), + domains, + })); + addMissingDomainWarning(context, protocol, importedId); + return context; + } + const domains = uniqueDomains([ + ...extractDomains(connection.metadata), + ...extractDomains(recordValue(connection.options)), + ]); + const context = { + organizationName: connectionName, + organizationExternalId: connection.id, + domains, + warnings, + }; + addMissingDomainWarning(context, protocol, importedId); + return context; +} +function addMissingDomainWarning(context, protocol, importedId) { + if (context.domains.length > 0) + return; + context.warnings.push(missingDomainsWarning({ + provider: 'auth0', + protocol, + importedId, + organizationExternalId: context.organizationExternalId, + organizationName: context.organizationName, + })); +} +function extractAttributeMappings(connection) { + const options = recordValue(connection.options); + const mappings = {}; + for (const key of ATTRIBUTE_MAPPING_KEYS) { + const candidate = getOptionValue(options, [key]) ?? connection[key]; + if (!isRecord(candidate)) + continue; + for (const [attribute, claim] of Object.entries(candidate)) { + const stringClaim = stringValue(claim); + if (!stringClaim) + continue; + mappings[attribute] = stringClaim; + } + } + return mappings; +} +function toCustomAttributeRows(attributeMappings, importedId, organization, providerType) { + return Object.entries(attributeMappings) + .filter(([attribute, claim]) => Boolean(claim) && !COMMON_PROFILE_ATTRIBUTES.has(attribute)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([attribute, claim]) => createCustomAttributeMappingRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + providerType, + userPoolAttribute: attribute, + idpClaim: claim, + })); +} +function buildCustomAttributesJson(attributeMappings) { + const customMappings = Object.fromEntries(Object.entries(attributeMappings) + .filter(([attribute, claim]) => Boolean(claim) && !COMMON_PROFILE_ATTRIBUTES.has(attribute)) + .sort(([a], [b]) => a.localeCompare(b))); + return Object.keys(customMappings).length > 0 ? JSON.stringify(customMappings) : ''; +} +function lookupMapping(attributeMappings, keys) { + for (const key of keys) { + const value = attributeMappings[key]; + if (value) + return value; + } + return ''; +} +function extractDomains(source) { + if (!isRecord(source)) + return []; + const values = [ + source.domains, + source.domain, + source.domain_aliases, + source.domainAliases, + source.email_domains, + source.emailDomains, + ]; + return uniqueDomains(values.flatMap((value) => parseDomainValue(value))); +} +function parseDomainValue(value) { + if (Array.isArray(value)) { + return value.flatMap((item) => parseDomainValue(item)); + } + const stringDomain = stringValue(value); + if (!stringDomain) + return []; + return stringDomain + .split(/[;,\s]+/) + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); +} +function uniqueDomains(domains) { + return [...new Set(domains.map((domain) => domain.trim().toLowerCase()).filter(Boolean))].sort(); +} +function getSecretValues(options, keys) { + return keys.filter((key) => Boolean(getFirstString(options, [key]))); +} +function getFirstString(record, keys) { + for (const key of keys) { + const value = stringValue(getOptionValue(record, [key])); + if (value) + return value; + } + return ''; +} +function getOptionValue(record, keys) { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + return record[key]; + } + } + return undefined; +} +function firstNonEmpty(...values) { + for (const value of values) { + const normalized = stringValue(value); + if (normalized) + return normalized; + } + return ''; +} +function stringValue(value) { + if (typeof value !== 'string') + return ''; + return value.trim(); +} +function recordValue(value) { + return isRecord(value) ? value : {}; +} +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +function shouldRedactKey(key) { + const normalized = key.replace(/[-_\s]/g, '').toLowerCase(); + return (normalized === 'secret' || + normalized.endsWith('secret') || + normalized === 'password' || + normalized.endsWith('password') || + normalized.endsWith('privatekey') || + normalized === 'requestsigningkey' || + normalized === 'assertionencryptionkey' || + normalized === 'nameidencryptionkey' || + normalized === 'accesstoken' || + normalized === 'refreshtoken' || + normalized === 'idtoken'); +} +function boolishString(value) { + if (value === undefined || value === null) + return ''; + if (typeof value === 'boolean') + return value ? 'TRUE' : 'FALSE'; + return stringValue(value); +} +function buildAuth0CallbackUrl(domain, connectionName) { + return `https://${domain}/login/callback?connection=${encodeURIComponent(connectionName)}`; +} diff --git a/dist/shared/types.d.ts b/dist/shared/types.d.ts index f1d7b59..ddf6bd0 100644 --- a/dist/shared/types.d.ts +++ b/dist/shared/types.d.ts @@ -92,6 +92,8 @@ export interface Auth0ExportOptions { output?: string; package?: boolean; outputDir?: string; + entities?: string[]; + includeSecrets?: boolean; orgs?: string[]; pageSize: number; rateLimit: number; diff --git a/dist/sso/handoff.d.ts b/dist/sso/handoff.d.ts index d0b4187..bd85400 100644 --- a/dist/sso/handoff.d.ts +++ b/dist/sso/handoff.d.ts @@ -21,7 +21,7 @@ export declare function writeProxyRoutesCsv(filePath: string, rows: ProxyRouteRo export declare function writeCsvRows(filePath: string, headers: readonly string[], rows: Record[]): Promise; /** Produce a CSV string from headers + rows. Handles commas, quotes, and newlines. */ export declare function rowsToCsv(headers: readonly string[], rows: Record[]): string; -export type SsoWarningCode = 'missing_domains' | 'secrets_redacted' | 'multi_org_connection_consolidated' | 'unsupported_connection_protocol'; +export type SsoWarningCode = 'missing_domains' | 'secrets_redacted' | 'multi_org_connection_consolidated' | 'unsupported_connection_protocol' | 'incomplete_connection_configuration'; export interface SsoHandoffWarning { code: SsoWarningCode; message: string; @@ -60,3 +60,11 @@ export declare function unsupportedConnectionProtocolWarning(input: { strategy?: string; reason?: string; }): SsoHandoffWarning; +export declare function incompleteConnectionConfigurationWarning(input: { + provider: string; + protocol: string; + importedId?: string; + strategy?: string; + missingFields: string[]; + reason?: string; +}): SsoHandoffWarning; diff --git a/dist/sso/handoff.js b/dist/sso/handoff.js index 86ea331..7a36972 100644 --- a/dist/sso/handoff.js +++ b/dist/sso/handoff.js @@ -107,6 +107,20 @@ export function unsupportedConnectionProtocolWarning(input) { }, }; } +export function incompleteConnectionConfigurationWarning(input) { + return { + code: 'incomplete_connection_configuration', + provider: input.provider, + protocol: input.protocol, + importedId: input.importedId, + message: `${input.provider} ${input.protocol} connection${input.importedId ? ` ${input.importedId}` : ''} was skipped because required handoff configuration was not available.`, + details: { + strategy: input.strategy, + missingFields: input.missingFields, + reason: input.reason, + }, + }; +} function createRow(headers, input) { const row = {}; for (const header of headers) { diff --git a/proxy-sample-auth0/README.md b/proxy-sample-auth0/README.md new file mode 100644 index 0000000..0156fc9 --- /dev/null +++ b/proxy-sample-auth0/README.md @@ -0,0 +1,93 @@ +# Auth0 SSO Migration Proxy - Sample Implementation + +A minimal Cloudflare Worker proxy for Auth0 -> WorkOS enterprise SSO migrations. It sits on the Auth0 custom domain callback route during cutover so customer IdPs can keep posting to the existing Auth0 callback while WorkOS handles migrated SAML/OIDC connections. + +This is a **reference implementation**, not a drop-in production binary. Adapt logging, rollout controls, monitoring, and deployment to your environment before production use. + +## Architecture + +```text +Customer IdP + | + | POST/GET https://auth0.example.com/login/callback + v +Cloudflare Worker on Auth0 custom domain + | + |-- no fallback=auth0 + | 307 -> https://workos.example.com/sso//auth0/callback + | + `-- fallback=auth0 + proxy -> https://.auth0.com/login/callback +``` + +The flow mirrors the Auth0 enterprise-connection migration guide: + +1. The IdP sends the SAML response or OIDC callback to the existing Auth0 callback URL. +2. The Worker redirects callback traffic to WorkOS at `/sso//auth0/callback`. +3. WorkOS resolves the imported connection by Auth0 connection information in the callback. +4. If WorkOS cannot process that callback, it sends the browser back with `fallback=auth0`. +5. The Worker strips `fallback=auth0` and forwards the original callback to Auth0. + +## Files + +| File | Purpose | +| ----------------------- | ------------------------------------------------ | +| `worker.js` | Cloudflare Worker callback proxy. | +| `wrangler.toml.example` | Minimal Wrangler config to adapt for deployment. | + +## Environment Variables + +Set these as Worker variables or secrets: + +- `WORKOS_CUSTOM_DOMAIN` - WorkOS custom auth domain, for example `workos.example.com`. +- `WORKOS_CLIENT_ID` - WorkOS environment client ID used in the callback path. +- `AUTH0_FALLBACK_ORIGIN` - Auth0 tenant origin to forward fallback traffic to, for example `https://my-tenant.us.auth0.com`. + +`AUTH0_FALLBACK_ORIGIN` should not be the same proxied custom-domain hostname or the fallback request can loop through the Worker. + +## Deploy With Wrangler + +```sh +cp wrangler.toml.example wrangler.toml +# Edit route, account_id, and vars. +npx wrangler deploy +``` + +Recommended Worker route: + +```text +auth0.example.com/login/callback* +``` + +Keep the Worker scoped to the callback route. Other Auth0 traffic should continue to route normally. + +## Rollback + +Rollback is DNS/routing-level: + +- Disable the Worker route to send all callback traffic directly to Auth0. +- Or add a temporary Worker rule that treats all callbacks as `fallback=auth0`. + +Per-connection rollback should normally happen in the application authorization flow by routing that connection back to Auth0 before the IdP callback is reached. + +## Local Smoke Tests + +With Wrangler dev running: + +```sh +curl -i "http://localhost:8787/login/callback?connection=okta" +curl -i "http://localhost:8787/login/callback?connection=okta&fallback=auth0" +``` + +Expected behavior: + +- First request returns `307` with a `Location` under `https:///sso//auth0/callback`. +- Second request is proxied to `AUTH0_FALLBACK_ORIGIN` after removing `fallback=auth0`. + +## Production Notes + +- Use a WorkOS custom domain before cutover so callback URLs remain stable. +- Preserve the request method and body. The sample uses `307` so browser POSTs remain POSTs. +- Keep query parameters intact except for the fallback flag when forwarding to Auth0. +- Add monitoring around WorkOS fallback volume and Auth0 fallback responses. +- Test in staging with one connection before routing broad production traffic. diff --git a/proxy-sample-auth0/worker.js b/proxy-sample-auth0/worker.js new file mode 100644 index 0000000..60abf9f --- /dev/null +++ b/proxy-sample-auth0/worker.js @@ -0,0 +1,75 @@ +/** + * Auth0 -> WorkOS enterprise SSO callback proxy. + * + * Deploy on the Auth0 custom-domain callback route: + * + * auth0.example.com/login/callback* + * + * Required Worker variables: + * + * WORKOS_CUSTOM_DOMAIN workos.example.com + * WORKOS_CLIENT_ID client_... + * AUTH0_FALLBACK_ORIGIN https://my-tenant.us.auth0.com + */ + +const CALLBACK_PATH = '/login/callback'; +const FALLBACK_PARAM = 'fallback'; +const FALLBACK_VALUE = 'auth0'; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname !== CALLBACK_PATH) { + return proxyToAuth0(request, env); + } + + if (url.searchParams.get(FALLBACK_PARAM) === FALLBACK_VALUE) { + return proxyToAuth0(request, env, { stripFallback: true }); + } + + return redirectToWorkOS(request, env); + }, +}; + +function redirectToWorkOS(request, env) { + assertEnv(env, 'WORKOS_CUSTOM_DOMAIN'); + assertEnv(env, 'WORKOS_CLIENT_ID'); + + const incoming = new URL(request.url); + const target = new URL( + `/sso/${encodeURIComponent(env.WORKOS_CLIENT_ID)}/auth0/callback`, + `https://${env.WORKOS_CUSTOM_DOMAIN}`, + ); + target.search = incoming.search; + + return Response.redirect(target.toString(), 307); +} + +function proxyToAuth0(request, env, options = {}) { + assertEnv(env, 'AUTH0_FALLBACK_ORIGIN'); + + const incoming = new URL(request.url); + const target = new URL(incoming.pathname, normalizeOrigin(env.AUTH0_FALLBACK_ORIGIN)); + target.search = incoming.search; + + if (options.stripFallback) { + target.searchParams.delete(FALLBACK_PARAM); + } + + return fetch(new Request(target.toString(), request)); +} + +function normalizeOrigin(origin) { + const url = new URL(origin); + url.pathname = ''; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +function assertEnv(env, key) { + if (!env[key]) { + throw new Error(`Missing required environment variable: ${key}`); + } +} diff --git a/proxy-sample-auth0/wrangler.toml.example b/proxy-sample-auth0/wrangler.toml.example new file mode 100644 index 0000000..8bc139e --- /dev/null +++ b/proxy-sample-auth0/wrangler.toml.example @@ -0,0 +1,16 @@ +name = "auth0-workos-sso-proxy" +main = "worker.js" +compatibility_date = "2026-05-01" + +# Replace with your Cloudflare account ID. +account_id = "00000000000000000000000000000000" + +# Scope the Worker to the Auth0 callback path only. +routes = [ + { pattern = "auth0.example.com/login/callback*", zone_name = "example.com" } +] + +[vars] +WORKOS_CUSTOM_DOMAIN = "workos.example.com" +WORKOS_CLIENT_ID = "client_000000000000000000000000" +AUTH0_FALLBACK_ORIGIN = "https://my-tenant.us.auth0.com" diff --git a/src/cli/commands/export-auth0.ts b/src/cli/commands/export-auth0.ts index b2334b1..9f80b72 100644 --- a/src/cli/commands/export-auth0.ts +++ b/src/cli/commands/export-auth0.ts @@ -13,6 +13,12 @@ export function registerExportAuth0Command(program: Command): void { .option('--output ', 'Output CSV file path') .option('--package', 'Write a provider-neutral migration package') .option('--output-dir ', 'Output directory for package mode') + .option( + '--entities ', + 'Comma-separated package entities to export (users,organizations,memberships,sso)', + 'users,organizations,memberships', + ) + .option('--include-secrets', 'Include Auth0 SSO connection secrets in package handoff files') .option('--orgs ', 'Filter to specific Auth0 org IDs') .option('--page-size ', 'API pagination size (max 100)', '100') .option('--rate-limit ', 'API requests per second', '50') @@ -43,6 +49,8 @@ export function registerExportAuth0Command(program: Command): void { output: opts.output, package: opts.package ?? false, outputDir: opts.outputDir, + entities: parseEntities(opts.entities), + includeSecrets: opts.includeSecrets ?? false, orgs: opts.orgs, pageSize: parseInt(opts.pageSize, 10), rateLimit: parseInt(opts.rateLimit, 10), @@ -63,3 +71,11 @@ export function registerExportAuth0Command(program: Command): void { } }); } + +function parseEntities(value: string | undefined): string[] | undefined { + if (!value) return undefined; + return value + .split(',') + .map((entity) => entity.trim()) + .filter(Boolean); +} diff --git a/src/exporters/auth0/__tests__/package-exporter.test.ts b/src/exporters/auth0/__tests__/package-exporter.test.ts index 9c36635..3e70821 100644 --- a/src/exporters/auth0/__tests__/package-exporter.test.ts +++ b/src/exporters/auth0/__tests__/package-exporter.test.ts @@ -2,7 +2,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { streamCSV } from '../../../shared/csv-utils'; -import type { Auth0Organization, Auth0User } from '../../../shared/types'; +import type { + Auth0Connection, + Auth0Organization, + Auth0OrganizationConnection, + Auth0User, +} from '../../../shared/types'; import { validateMigrationPackage } from '../../../package/validator'; import { exportAuth0PackageWithClient, type Auth0ExportClient } from '../package-exporter'; @@ -11,12 +16,34 @@ class FakeAuth0Client implements Auth0ExportClient { private readonly organizations: Auth0Organization[], private readonly membersByOrg: Record>, private readonly usersById: Record, + private readonly connections: Auth0Connection[] = [], + private readonly organizationConnectionsByOrg: Record< + string, + Auth0OrganizationConnection[] + > = {}, ) {} + async getConnections(page = 0): Promise { + return page === 0 ? this.connections : []; + } + + async getConnection(connectionId: string): Promise { + const connection = this.connections.find((item) => item.id === connectionId); + if (!connection) throw new Error(`Unknown connection ${connectionId}`); + return connection; + } + async getOrganizations(page = 0): Promise { return page === 0 ? this.organizations : []; } + async getOrganizationConnections( + orgId: string, + page = 0, + ): Promise { + return page === 0 ? (this.organizationConnectionsByOrg[orgId] ?? []) : []; + } + async getOrganizationMembers(orgId: string, page = 0): Promise> { return page === 0 ? (this.membersByOrg[orgId] ?? []) : []; } @@ -294,6 +321,197 @@ describe('exportAuth0PackageWithClient', () => { }, ]); }); + + it('writes SSO-only handoff package files with warnings and redacted raw snapshots', async () => { + const samlConnection: Auth0Connection = { + id: 'con_saml', + name: 'okta', + strategy: 'samlp', + options: { + entityId: 'https://idp.example.com/entity', + signInEndpoint: 'https://idp.example.com/sso', + signingCert: 'CERTDATA', + fieldsMap: { + email: 'mail', + department: 'department', + }, + }, + }; + const oidcConnection: Auth0Connection = { + id: 'con_oidc', + name: 'oidc-idp', + strategy: 'oidc', + options: { + client_id: 'client_123', + client_secret: 'oidc-secret', + issuer: 'https://issuer.example.com', + mapping: { + title: 'title', + }, + }, + }; + const unsupportedConnection: Auth0Connection = { + id: 'con_db', + name: 'Username-Password-Authentication', + strategy: 'auth0', + }; + const incompleteConnection: Auth0Connection = { + id: 'con_incomplete', + name: 'incomplete-saml', + strategy: 'samlp', + options: { + signInEndpoint: 'https://idp.example.com/sso', + }, + }; + const client = new FakeAuth0Client( + [org], + {}, + {}, + [samlConnection, oidcConnection, unsupportedConnection, incompleteConnection], + { + [org.id]: [{ connection_id: 'con_saml' }, { connection_id: 'con_oidc' }], + }, + ); + + const summary = await exportAuth0PackageWithClient(client, { + domain: 'example.us.auth0.com', + clientId: 'client_123', + clientSecret: 'secret', + package: true, + outputDir: tempRoot, + entities: ['sso'], + pageSize: 100, + rateLimit: 50, + userFetchConcurrency: 2, + useMetadata: false, + quiet: true, + }); + + expect(summary).toMatchObject({ + totalUsers: 0, + totalOrgs: 0, + skippedUsers: 0, + }); + + const validation = await validateMigrationPackage(tempRoot); + expect(validation.valid).toBe(true); + expect(validation.manifest?.entitiesRequested).toEqual(['sso']); + expect(validation.manifest?.entitiesExported).toMatchObject({ + users: 0, + organizations: 0, + memberships: 0, + samlConnections: 1, + oidcConnections: 1, + customAttributeMappings: 2, + proxyRoutes: 2, + warnings: 3, + skippedUsers: 0, + }); + expect(validation.manifest?.secretRedaction).toMatchObject({ + mode: 'redacted', + redacted: true, + }); + + expect(await readCsv(path.join(tempRoot, 'sso', 'saml_connections.csv'))).toMatchObject([ + { + organizationName: 'Acme', + organizationExternalId: 'org_abc123', + domains: 'acme.com,login.acme.com', + idpEntityId: 'https://idp.example.com/entity', + idpUrl: 'https://idp.example.com/sso', + x509Cert: 'CERTDATA', + emailAttribute: 'mail', + importedId: 'auth0:con_saml', + }, + ]); + + expect(await readCsv(path.join(tempRoot, 'sso', 'oidc_connections.csv'))).toMatchObject([ + { + organizationExternalId: 'org_abc123', + clientId: 'client_123', + clientSecret: '', + discoveryEndpoint: 'https://issuer.example.com/.well-known/openid-configuration', + importedId: 'auth0:con_oidc', + }, + ]); + + expect( + await readCsv(path.join(tempRoot, 'sso', 'custom_attribute_mappings.csv')), + ).toMatchObject([ + { + importedId: 'auth0:con_saml', + userPoolAttribute: 'department', + idpClaim: 'department', + }, + { + importedId: 'auth0:con_oidc', + userPoolAttribute: 'title', + idpClaim: 'title', + }, + ]); + expect(await readCsv(path.join(tempRoot, 'sso', 'proxy_routes.csv'))).toHaveLength(2); + + const warnings = readJsonl(path.join(tempRoot, 'warnings.jsonl')); + expect(warnings.map((warning) => warning.code).sort()).toEqual([ + 'incomplete_connection_configuration', + 'secrets_redacted', + 'unsupported_connection_protocol', + ]); + + const raw = fs.readFileSync(path.join(tempRoot, 'raw', 'auth0-connections.jsonl'), 'utf-8'); + expect(raw).toContain('"client_secret":"[REDACTED]"'); + expect(raw).not.toContain('oidc-secret'); + }); + + it('includes SSO secrets when explicitly requested', async () => { + const oidcConnection: Auth0Connection = { + id: 'con_oidc', + name: 'oidc-idp', + strategy: 'oidc', + options: { + client_id: 'client_123', + client_secret: 'oidc-secret', + issuer: 'https://issuer.example.com', + }, + }; + const client = new FakeAuth0Client([org], {}, {}, [oidcConnection], { + [org.id]: [{ connection_id: 'con_oidc' }], + }); + + await exportAuth0PackageWithClient(client, { + domain: 'example.us.auth0.com', + clientId: 'client_123', + clientSecret: 'secret', + package: true, + outputDir: tempRoot, + entities: ['sso'], + includeSecrets: true, + pageSize: 100, + rateLimit: 50, + userFetchConcurrency: 2, + useMetadata: false, + quiet: true, + }); + + const validation = await validateMigrationPackage(tempRoot); + expect(validation.valid).toBe(true); + expect(validation.manifest?.secretRedaction).toMatchObject({ + mode: 'included', + redacted: false, + }); + expect(validation.manifest?.secretsRedacted).toBe(false); + + expect(await readCsv(path.join(tempRoot, 'sso', 'oidc_connections.csv'))).toMatchObject([ + { + clientId: 'client_123', + clientSecret: 'oidc-secret', + }, + ]); + expect( + fs.readFileSync(path.join(tempRoot, 'raw', 'auth0-connections.jsonl'), 'utf-8'), + ).toContain('oidc-secret'); + expect(readJsonl(path.join(tempRoot, 'warnings.jsonl'))).toEqual([]); + }); }); async function readCsv(filePath: string): Promise[]> { diff --git a/src/exporters/auth0/__tests__/sso-mapper.test.ts b/src/exporters/auth0/__tests__/sso-mapper.test.ts new file mode 100644 index 0000000..577324b --- /dev/null +++ b/src/exporters/auth0/__tests__/sso-mapper.test.ts @@ -0,0 +1,242 @@ +import type { Auth0Connection, Auth0Organization } from '../../../shared/types'; +import { + classifyAuth0ConnectionProtocol, + mapAuth0ConnectionToSsoHandoff, + redactAuth0ConnectionSecrets, +} from '../sso-mapper'; + +const org: Auth0Organization = { + id: 'org_acme', + name: 'acme', + display_name: 'Acme', + metadata: { + domains: ['acme.com'], + }, +}; + +describe('Auth0 SSO handoff mapper', () => { + it('maps complete SAML enterprise connections into handoff rows', () => { + const connection: Auth0Connection = { + id: 'con_saml', + name: 'okta', + strategy: 'samlp', + options: { + entityId: 'https://idp.example.com/entity', + signInEndpoint: 'https://idp.example.com/sso', + signingCert: 'CERTDATA', + fieldsMap: { + email: 'mail', + given_name: 'firstName', + family_name: 'lastName', + department: 'department', + }, + }, + }; + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }], + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.protocol).toBe('saml'); + expect(result.samlRow).toMatchObject({ + organizationName: 'Acme', + organizationExternalId: 'org_acme', + domains: 'acme.com', + idpEntityId: 'https://idp.example.com/entity', + idpUrl: 'https://idp.example.com/sso', + x509Cert: 'CERTDATA', + emailAttribute: 'mail', + firstNameAttribute: 'firstName', + lastNameAttribute: 'lastName', + importedId: 'auth0:con_saml', + }); + expect(result.customAttributeRows).toMatchObject([ + { + importedId: 'auth0:con_saml', + organizationExternalId: 'org_acme', + providerType: 'SAML', + userPoolAttribute: 'department', + idpClaim: 'department', + }, + ]); + expect(result.proxyRouteRow.sourceAcsUrl).toBe( + 'https://tenant.auth0.com/login/callback?connection=okta', + ); + expect(result.warnings).toEqual([]); + }); + + it('maps OIDC enterprise connections and redacts secrets unless explicitly included', () => { + const connection: Auth0Connection = { + id: 'con_oidc', + name: 'oidc-idp', + strategy: 'oidc', + options: { + client_id: 'client_123', + client_secret: 'super-secret', + issuer: 'https://issuer.example.com', + mapping: { + name: 'name', + title: 'title', + }, + }, + }; + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }], + includeSecrets: false, + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.protocol).toBe('oidc'); + expect(result.oidcRow).toMatchObject({ + clientId: 'client_123', + clientSecret: '', + discoveryEndpoint: 'https://issuer.example.com/.well-known/openid-configuration', + name: 'name', + importedId: 'auth0:con_oidc', + }); + expect(result.customAttributeRows).toMatchObject([ + { + providerType: 'OIDC', + userPoolAttribute: 'title', + idpClaim: 'title', + }, + ]); + expect(result.warnings).toMatchObject([ + { + code: 'secrets_redacted', + importedId: 'auth0:con_oidc', + }, + ]); + }); + + it('skips unsupported Auth0 connection strategies', () => { + const connection: Auth0Connection = { + id: 'con_db', + name: 'Username-Password-Authentication', + strategy: 'auth0', + }; + + expect(classifyAuth0ConnectionProtocol(connection)).toBe('unsupported'); + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + }); + + expect(result).toMatchObject({ + status: 'skipped', + protocol: 'unsupported', + reason: 'unsupported_connection_protocol', + warnings: [ + { + code: 'unsupported_connection_protocol', + importedId: 'auth0:con_db', + }, + ], + }); + }); + + it('skips SAML connections missing required handoff configuration', () => { + const connection: Auth0Connection = { + id: 'con_incomplete', + name: 'incomplete-saml', + strategy: 'samlp', + options: { + signInEndpoint: 'https://idp.example.com/sso', + }, + }; + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + }); + + expect(result).toMatchObject({ + status: 'skipped', + protocol: 'saml', + reason: 'incomplete_connection_configuration', + warnings: [ + { + code: 'incomplete_connection_configuration', + importedId: 'auth0:con_incomplete', + details: { + missingFields: ['idpEntityId', 'x509Cert'], + }, + }, + ], + }); + }); + + it('consolidates multi-org source connections into one handoff row with domain union', () => { + const connection: Auth0Connection = { + id: 'con_shared', + name: 'shared-saml', + strategy: 'samlp', + options: { + entityId: 'https://idp.example.com/entity', + signInEndpoint: 'https://idp.example.com/sso', + signingCert: 'CERTDATA', + }, + }; + const otherOrg: Auth0Organization = { + id: 'org_other', + name: 'other', + display_name: 'Other', + metadata: { + domains: ['other.com', 'acme.com'], + }, + }; + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }, { organization: otherOrg }], + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.samlRow).toMatchObject({ + organizationName: 'shared-saml', + organizationExternalId: 'con_shared', + domains: 'acme.com,other.com', + }); + expect(result.warnings).toMatchObject([ + { + code: 'multi_org_connection_consolidated', + importedId: 'auth0:con_shared', + details: { + sourceOrganizationIds: ['org_acme', 'org_other'], + domains: ['acme.com', 'other.com'], + }, + }, + ]); + }); + + it('redacts Auth0 connection secrets without redacting public certificates or endpoints', () => { + const redacted = redactAuth0ConnectionSecrets({ + options: { + client_secret: 'super-secret', + token_endpoint: 'https://issuer.example.com/oauth/token', + signingCert: 'PUBLIC_CERT', + private_key: 'PRIVATE_KEY', + }, + }); + + expect(redacted).toEqual({ + options: { + client_secret: '[REDACTED]', + token_endpoint: 'https://issuer.example.com/oauth/token', + signingCert: 'PUBLIC_CERT', + private_key: '[REDACTED]', + }, + }); + }); +}); diff --git a/src/exporters/auth0/package-exporter.ts b/src/exporters/auth0/package-exporter.ts index 3413e93..bee6a61 100644 --- a/src/exporters/auth0/package-exporter.ts +++ b/src/exporters/auth0/package-exporter.ts @@ -1,7 +1,9 @@ +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, @@ -11,19 +13,50 @@ import { } from '../../package/writer.js'; import { createCSVWriter } from '../../shared/csv-utils.js'; import type { + Auth0Connection, Auth0ExportOptions, Auth0Organization, + Auth0OrganizationConnection, Auth0User, CSVRow, ExportSummary, } from '../../shared/types.js'; +import { + writeCustomAttributeMappingsCsv, + writeOidcConnectionsCsv, + writeProxyRoutesCsv, + writeSamlConnectionsCsv, + type CustomAttrRow, + type OidcRow, + type ProxyRouteRow, + type SamlRow, + type SsoHandoffWarning, +} from '../../sso/handoff.js'; import * as logger from '../../shared/logger.js'; -import { Auth0Client } from './client.js'; +import { Auth0Client, isMissingConnectionOptionsScopeError } from './client.js'; import { extractOrgFromMetadata, isFederatedAuth0User, mapAuth0UserToWorkOS } from './mapper.js'; +import { + AUTH0_REDACTED_SECRET_FIELDS, + classifyAuth0ConnectionProtocol, + mapAuth0ConnectionToSsoHandoff, + redactAuth0ConnectionSecrets, + type Auth0SsoConnectionOrgBinding, +} from './sso-mapper.js'; export interface Auth0ExportClient { testConnection?(): Promise<{ success: boolean; error?: string }>; + getConnections?( + page?: number, + perPage?: number, + strategy?: string | string[], + ): Promise; + getConnection?(connectionId: string): Promise; getOrganizations(page?: number, perPage?: number): Promise; + getOrganizationConnections?( + orgId: string, + page?: number, + perPage?: number, + ): Promise; getOrganizationMembers( orgId: string, page?: number, @@ -50,12 +83,19 @@ interface Auth0WarningRecord { org_id?: string; org_name?: string; user_id?: string; + connection_id?: string; + protocol?: string; + details?: Record; } interface Auth0PackageStats { totalUsers: number; totalOrgs: number; totalMemberships: number; + samlConnections: number; + oidcConnections: number; + customAttributeMappings: number; + proxyRoutes: number; skippedUsers: number; warnings: Auth0WarningRecord[]; skipped: Auth0SkippedUserRecord[]; @@ -64,6 +104,8 @@ interface Auth0PackageStats { 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 DEFAULT_PACKAGE_ENTITIES = ['users', 'organizations', 'memberships'] as const; +const SUPPORTED_PACKAGE_ENTITIES = new Set([...DEFAULT_PACKAGE_ENTITIES, 'sso']); export async function exportAuth0Package(options: Auth0ExportOptions): Promise { const client = new Auth0Client({ @@ -100,15 +142,37 @@ export async function exportAuth0PackageWithClient( } const resolvedOutputDir = path.resolve(outputDir); - await createEmptyPackageFiles(resolvedOutputDir, buildHandoffNotes()); + const requestedEntities = normalizeRequestedEntities(options.entities); + const shouldExportIdentityEntities = requestedEntities.some((entity) => + DEFAULT_PACKAGE_ENTITIES.includes(entity as (typeof DEFAULT_PACKAGE_ENTITIES)[number]), + ); + const shouldExportSso = requestedEntities.includes('sso'); + + await createEmptyPackageFiles( + resolvedOutputDir, + buildHandoffNotes({ + includeSso: shouldExportSso, + includeSecrets: options.includeSecrets ?? false, + }), + ); if (!options.quiet) { logger.info(`Writing Auth0 migration package to ${resolvedOutputDir}`); } - const stats = options.useMetadata - ? await exportPackageUsersWithMetadata(client, resolvedOutputDir, options) - : await exportPackageOrganizations(client, resolvedOutputDir, options); + const stats = createEmptyPackageStats(); + + if (shouldExportIdentityEntities) { + const identityStats = options.useMetadata + ? await exportPackageUsersWithMetadata(client, resolvedOutputDir, options) + : await exportPackageOrganizations(client, resolvedOutputDir, options); + mergePackageStats(stats, identityStats); + } + + if (shouldExportSso) { + const ssoStats = await exportPackageSso(client, resolvedOutputDir, options); + mergePackageStats(stats, ssoStats); + } await writePackageJsonlRecords(resolvedOutputDir, 'warnings', stats.warnings); await writePackageJsonlRecords(resolvedOutputDir, 'skippedUsers', stats.skipped); @@ -117,20 +181,20 @@ export async function exportAuth0PackageWithClient( provider: 'auth0', sourceTenant: options.domain, generatedAt: new Date(), - entitiesRequested: ['users', 'organizations', 'memberships'], + entitiesRequested: requestedEntities, entitiesExported: { users: stats.totalUsers, organizations: stats.totalOrgs, memberships: stats.totalMemberships, + samlConnections: stats.samlConnections, + oidcConnections: stats.oidcConnections, + customAttributeMappings: stats.customAttributeMappings, + proxyRoutes: stats.proxyRoutes, warnings: stats.warnings.length, skippedUsers: stats.skipped.length, }, - secretsRedacted: true, - secretRedaction: { - mode: 'not-applicable', - redacted: true, - notes: ['Auth0 package core does not export connection secrets or password hashes.'], - }, + secretsRedacted: shouldExportSso ? !(options.includeSecrets ?? false) : true, + secretRedaction: buildSecretRedactionMetadata(shouldExportSso, options.includeSecrets ?? false), warnings: stats.warnings.map((warning) => warning.message), }); @@ -142,6 +206,11 @@ export async function exportAuth0PackageWithClient( logger.info(` Organizations: ${stats.totalOrgs}`); logger.info(` Memberships: ${stats.totalMemberships}`); logger.info(` User rows exported: ${stats.totalUsers}`); + if (shouldExportSso) { + logger.info(` SAML connections: ${stats.samlConnections}`); + logger.info(` OIDC connections: ${stats.oidcConnections}`); + logger.info(` Proxy routes: ${stats.proxyRoutes}`); + } if (stats.skippedUsers > 0) { logger.warn(` Users skipped: ${stats.skippedUsers}`); } @@ -182,14 +251,8 @@ async function exportPackageOrganizations( ...MEMBERSHIP_HEADERS, ]); - const stats: Auth0PackageStats = { - totalUsers: 0, - totalOrgs: organizations.length, - totalMemberships: 0, - skippedUsers: 0, - warnings: [], - skipped: [], - }; + const stats = createEmptyPackageStats(); + stats.totalOrgs = organizations.length; try { for (const org of organizations) { @@ -291,14 +354,7 @@ async function exportPackageUsersWithMetadata( ...MEMBERSHIP_HEADERS, ]); - const stats: Auth0PackageStats = { - totalUsers: 0, - totalOrgs: 0, - totalMemberships: 0, - skippedUsers: 0, - warnings: [], - skipped: [], - }; + const stats = createEmptyPackageStats(); const seenOrgs = new Set(); if (!options.quiet) { @@ -358,6 +414,93 @@ async function exportPackageUsersWithMetadata( return stats; } +async function exportPackageSso( + client: Auth0ExportClient, + outputDir: string, + options: Auth0ExportOptions, +): Promise { + if (!client.getConnections) { + throw new Error('Auth0 SSO package export requires Management API connection support.'); + } + + const stats = createEmptyPackageStats(); + + let connections: Auth0Connection[]; + try { + connections = await fetchAllConnections(client, options.pageSize); + } catch (error: unknown) { + if (!isMissingConnectionOptionsScopeError(error)) throw error; + addWarning( + stats, + 'missing_connections_options_scope', + 'Auth0 SSO connection export was skipped because the Management API token is missing read:connections_options.', + ); + await writeRawAuth0Connections(outputDir, [], options.includeSecrets ?? false); + return stats; + } + + const organizations = await fetchOrganizationsForSso(client, options); + const orgBindingsByConnectionId = await fetchOrganizationConnectionBindings( + client, + organizations, + options, + stats, + ); + + const hydratedConnections = await hydrateSsoConnections(client, connections, stats); + const samlRows: SamlRow[] = []; + const oidcRows: OidcRow[] = []; + const customAttributeRows: CustomAttrRow[] = []; + const proxyRouteRows: ProxyRouteRow[] = []; + const candidateConnections = hydratedConnections.filter( + (connection) => !options.orgs || orgBindingsByConnectionId.has(connection.id), + ); + + for (const connection of candidateConnections) { + const mapping = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: options.domain, + orgBindings: orgBindingsByConnectionId.get(connection.id) ?? [], + includeSecrets: options.includeSecrets ?? false, + }); + + for (const warning of mapping.warnings) { + addSsoWarning(stats, warning); + } + + if (mapping.status !== 'mapped') continue; + + if (mapping.samlRow) samlRows.push(mapping.samlRow); + if (mapping.oidcRow) oidcRows.push(mapping.oidcRow); + customAttributeRows.push(...mapping.customAttributeRows); + proxyRouteRows.push(mapping.proxyRouteRow); + } + + await Promise.all([ + writeSamlConnectionsCsv(getPackageFilePath(outputDir, 'samlConnections'), samlRows), + writeOidcConnectionsCsv(getPackageFilePath(outputDir, 'oidcConnections'), oidcRows), + writeCustomAttributeMappingsCsv( + getPackageFilePath(outputDir, 'customAttributeMappings'), + customAttributeRows, + ), + writeProxyRoutesCsv(getPackageFilePath(outputDir, 'proxyRoutes'), proxyRouteRows), + writeRawAuth0Connections(outputDir, candidateConnections, options.includeSecrets ?? false), + ]); + + stats.samlConnections = samlRows.length; + stats.oidcConnections = oidcRows.length; + stats.customAttributeMappings = customAttributeRows.length; + stats.proxyRoutes = proxyRouteRows.length; + + if (!options.quiet) { + logger.info( + `Exported ${stats.samlConnections + stats.oidcConnections} SSO handoff connection row(s)`, + ); + } + + return stats; +} + function writePackageUserAndMembership( user: Auth0User | null, org: Auth0Organization | undefined, @@ -419,6 +562,136 @@ async function fetchAllOrganizations( return allOrgs; } +async function fetchOrganizationsForSso( + client: Auth0ExportClient, + options: Auth0ExportOptions, +): Promise { + try { + const organizations = await fetchAllOrganizations(client, options.pageSize); + return options.orgs + ? organizations.filter((organization) => options.orgs?.includes(organization.id)) + : organizations; + } catch (error: unknown) { + if (!options.quiet) { + logger.warn( + `Unable to fetch Auth0 organizations for SSO binding: ${(error as Error).message}`, + ); + } + return []; + } +} + +async function fetchAllConnections( + client: Auth0ExportClient, + pageSize: number, +): Promise { + if (!client.getConnections) return []; + + const connections: Auth0Connection[] = []; + let page = 0; + let hasMore = true; + + while (hasMore) { + const batch = await client.getConnections(page, pageSize); + if (batch.length === 0) break; + connections.push(...batch); + hasMore = batch.length >= pageSize; + page++; + } + + return connections; +} + +async function fetchOrganizationConnectionBindings( + client: Auth0ExportClient, + organizations: Auth0Organization[], + options: Auth0ExportOptions, + stats: Auth0PackageStats, +): Promise> { + const bindings = new Map(); + if (!client.getOrganizationConnections || organizations.length === 0) return bindings; + + for (const organization of organizations) { + try { + let page = 0; + let hasMore = true; + + while (hasMore) { + const orgConnections = await client.getOrganizationConnections( + organization.id, + page, + options.pageSize, + ); + if (orgConnections.length === 0) break; + + for (const organizationConnection of orgConnections) { + const connectionId = + organizationConnection.connection_id || organizationConnection.connection?.id; + if (!connectionId) continue; + const existing = bindings.get(connectionId) ?? []; + existing.push({ + organization, + organizationConnection, + }); + bindings.set(connectionId, existing); + } + + hasMore = orgConnections.length >= options.pageSize; + page++; + } + } catch (error: unknown) { + addWarning( + stats, + 'org_connection_fetch_failed', + `Failed to fetch enabled Auth0 connections for org ${organization.id}: ${ + (error as Error).message + }`, + organization, + ); + } + } + + return bindings; +} + +async function hydrateSsoConnections( + client: Auth0ExportClient, + connections: Auth0Connection[], + stats: Auth0PackageStats, +): Promise { + if (!client.getConnection) return connections; + + const hydrated: Auth0Connection[] = []; + for (const connection of connections) { + const protocol = classifyAuth0ConnectionProtocol(connection); + if (protocol === 'unsupported' || hasReadableOptions(connection)) { + hydrated.push(connection); + continue; + } + + try { + hydrated.push(await client.getConnection(connection.id)); + } catch (error: unknown) { + if (!isMissingConnectionOptionsScopeError(error)) throw error; + addWarning( + stats, + 'missing_connections_options_scope', + `Auth0 connection ${connection.id} options could not be read because the token is missing read:connections_options.`, + undefined, + undefined, + connection, + ); + hydrated.push(connection); + } + } + + return hydrated; +} + +function hasReadableOptions(connection: Auth0Connection): boolean { + return Boolean(connection.options && Object.keys(connection.options).length > 0); +} + function toOrganizationRow(org: Auth0Organization): Record { return { org_id: '', @@ -463,6 +736,117 @@ function normalizeCsvRow(row: CSVRow, headers: readonly string[]): Record { + const rawDir = path.join(outputDir, 'raw'); + await fs.mkdir(rawDir, { recursive: true }); + const records = connections.map((connection) => + includeSecrets ? connection : redactAuth0ConnectionSecrets(connection), + ); + const contents = records.map((record) => JSON.stringify(record)).join('\n'); + await fs.writeFile( + path.join(rawDir, 'auth0-connections.jsonl'), + contents ? `${contents}\n` : '', + 'utf-8', + ); +} + +function createEmptyPackageStats(): Auth0PackageStats { + return { + totalUsers: 0, + totalOrgs: 0, + totalMemberships: 0, + samlConnections: 0, + oidcConnections: 0, + customAttributeMappings: 0, + proxyRoutes: 0, + skippedUsers: 0, + warnings: [], + skipped: [], + }; +} + +function mergePackageStats(target: Auth0PackageStats, source: Auth0PackageStats): void { + target.totalUsers += source.totalUsers; + target.totalOrgs += source.totalOrgs; + target.totalMemberships += source.totalMemberships; + target.samlConnections += source.samlConnections; + target.oidcConnections += source.oidcConnections; + target.customAttributeMappings += source.customAttributeMappings; + target.proxyRoutes += source.proxyRoutes; + target.skippedUsers += source.skippedUsers; + target.warnings.push(...source.warnings); + target.skipped.push(...source.skipped); +} + +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 Auth0 package entity "${entity}". Supported entities: ${[ + ...SUPPORTED_PACKAGE_ENTITIES, + ].join(', ')}`, + ); + } + } + + return normalized.length > 0 ? normalized : [...DEFAULT_PACKAGE_ENTITIES]; +} + +function buildSecretRedactionMetadata( + includeSso: boolean, + includeSecrets: boolean, +): SecretRedactionMetadata { + if (!includeSso) { + return { + mode: 'not-applicable', + redacted: true, + notes: ['Auth0 package core does not export connection secrets or password hashes.'], + }; + } + + if (includeSecrets) { + return { + mode: 'included', + redacted: false, + files: [ + 'raw/auth0-connections.jsonl', + 'sso/oidc_connections.csv', + 'sso/saml_connections.csv', + ], + notes: ['Auth0 SSO connection secrets were included because --include-secrets was set.'], + }; + } + + return { + mode: 'redacted', + redacted: true, + redactedFields: [...AUTH0_REDACTED_SECRET_FIELDS], + files: ['raw/auth0-connections.jsonl', 'sso/oidc_connections.csv', 'sso/saml_connections.csv'], + notes: [ + 'Auth0 SSO connection secrets are redacted by default. Re-run with --include-secrets only when the output directory can safely store secrets.', + ], + }; +} + function addSkipped( stats: Auth0PackageStats, userId: string | undefined, @@ -489,6 +873,8 @@ function addWarning( message: string, org?: Auth0Organization, userId?: string, + connection?: Auth0Connection, + details?: Record, ): void { stats.warnings.push({ timestamp: new Date().toISOString(), @@ -496,9 +882,31 @@ function addWarning( message, ...(org ? { org_id: org.id, org_name: org.display_name || org.name } : {}), ...(userId ? { user_id: userId } : {}), + ...(connection + ? { connection_id: connection.id, protocol: classifyAuth0ConnectionProtocol(connection) } + : {}), + ...(details ? { details } : {}), }); } +function addSsoWarning(stats: Auth0PackageStats, warning: SsoHandoffWarning): void { + stats.warnings.push({ + timestamp: new Date().toISOString(), + code: warning.code, + message: warning.message, + ...(warning.organizationExternalId ? { org_id: warning.organizationExternalId } : {}), + ...(warning.importedId + ? { connection_id: auth0ConnectionIdFromImportedId(warning.importedId) } + : {}), + ...(warning.protocol ? { protocol: warning.protocol } : {}), + ...(warning.details ? { details: warning.details } : {}), + }); +} + +function auth0ConnectionIdFromImportedId(importedId: string): string { + return importedId.startsWith('auth0:') ? importedId.slice('auth0:'.length) : importedId; +} + function extractDomains(metadata: Record | undefined): string[] { const raw = metadata?.domains ?? metadata?.domain; if (!raw) return []; @@ -512,12 +920,26 @@ function extractDomains(metadata: Record | undefined): string[] return []; } -function buildHandoffNotes(): string { +function buildHandoffNotes(input: { includeSso: boolean; includeSecrets: boolean }): string { + if (!input.includeSso) { + return [ + '# Auth0 SSO handoff notes', + '', + 'This package was generated without Auth0 SSO connection handoff files.', + 'Run package mode with --entities sso, or include sso in a comma-separated entity list, when SSO handoff is needed.', + '', + ].join('\n'); + } + return [ '# Auth0 SSO handoff notes', '', - 'This package was generated by Auth0 package core and does not include SAML/OIDC connection handoff files yet.', - 'Run the Auth0 SSO handoff export phase when connection export support is enabled.', + 'Auth0 SSO export is handoff-only. The package writes SAML and OIDC connection CSVs for WorkOS/manual processing and does not create WorkOS SSO connections automatically.', + 'Only Auth0 `samlp` and `oidc` enterprise connections with enough configuration are emitted. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings.', + 'If one Auth0 connection is enabled for multiple Auth0 organizations, the exporter writes one handoff row with the union of source organization domains and a confirmation warning.', + input.includeSecrets + ? 'Connection secrets were included because --include-secrets was set.' + : 'Connection secrets were redacted. Re-run with --include-secrets only if the output directory can safely store secrets.', '', ].join('\n'); } diff --git a/src/exporters/auth0/sso-mapper.ts b/src/exporters/auth0/sso-mapper.ts new file mode 100644 index 0000000..b78b761 --- /dev/null +++ b/src/exporters/auth0/sso-mapper.ts @@ -0,0 +1,726 @@ +import type { + Auth0Connection, + Auth0Organization, + Auth0OrganizationConnection, +} from '../../shared/types.js'; +import { + createCustomAttributeMappingRow, + createOidcConnectionRow, + createProxyRouteRow, + createSamlConnectionRow, + incompleteConnectionConfigurationWarning, + missingDomainsWarning, + multiOrgConnectionConsolidationWarning, + redactedSecretsWarning, + unsupportedConnectionProtocolWarning, + type CustomAttrRow, + type OidcRow, + type ProxyRouteRow, + type SamlRow, + type SsoHandoffWarning, +} from '../../sso/handoff.js'; +import { normalizeDiscoveryEndpoint, parseSamlMetadata } from '../../sso/saml-metadata.js'; + +export type Auth0SsoProtocol = 'saml' | 'oidc'; +export type Auth0SsoClassification = Auth0SsoProtocol | 'unsupported'; + +export interface Auth0SsoConnectionOrgBinding { + organization: Auth0Organization; + organizationConnection?: Auth0OrganizationConnection; +} + +export interface Auth0SsoMappingInput { + connection: Auth0Connection; + domain: string; + orgBindings?: Auth0SsoConnectionOrgBinding[]; + includeSecrets?: boolean; +} + +export type Auth0SsoConnectionMapping = + | { + status: 'mapped'; + protocol: Auth0SsoProtocol; + importedId: string; + samlRow?: SamlRow; + oidcRow?: OidcRow; + customAttributeRows: CustomAttrRow[]; + proxyRouteRow: ProxyRouteRow; + warnings: SsoHandoffWarning[]; + } + | { + status: 'skipped'; + protocol: Auth0SsoClassification; + importedId: string; + reason: string; + warnings: SsoHandoffWarning[]; + }; + +export const AUTH0_REDACTED_SECRET_FIELDS = [ + 'client_secret', + 'clientSecret', + 'secret', + 'password', + 'private_key', + 'privateKey', + 'requestSigningKey', + 'assertionEncryptionKey', + 'nameIdEncryptionKey', + 'access_token', + 'refresh_token', + 'id_token', +] as const; + +const SAML_XML_OPTION_KEYS = [ + 'metadataXml', + 'metadataXML', + 'metadataFile', + 'metadata_file', + 'idpMetadataXml', + 'idp_metadata_xml', +] as const; + +const SAML_METADATA_URL_KEYS = [ + 'metadataUrl', + 'metadataURL', + 'metadata_url', + 'idpMetadataUrl', + 'idp_metadata_url', + 'MetadataURL', +] as const; + +const SAML_IDP_ENTITY_ID_KEYS = [ + 'idpEntityId', + 'idp_entity_id', + 'entityId', + 'entityID', + 'issuer', + 'idpIssuer', +] as const; + +const SAML_IDP_URL_KEYS = [ + 'signInEndpoint', + 'signin_url', + 'signInUrl', + 'ssoUrl', + 'sso_url', + 'idpUrl', + 'idp_url', + 'SSORedirectBindingURI', +] as const; + +const SAML_CERT_KEYS = [ + 'signingCert', + 'signing_cert', + 'x509Cert', + 'x509cert', + 'x509_certificate', + 'cert', + 'certificate', +] as const; + +const SAML_SP_ENTITY_ID_KEYS = [ + 'audience', + 'spEntityId', + 'sp_entity_id', + 'serviceProviderEntityId', +] as const; + +const SAML_ACS_URL_KEYS = [ + 'callbackUrl', + 'callbackURL', + 'acsUrl', + 'acs_url', + 'recipient', + 'destination', +] as const; + +const SAML_SECRET_KEYS = [ + 'requestSigningKey', + 'request_signing_key', + 'assertionEncryptionKey', + 'assertion_encryption_key', + 'nameIdEncryptionKey', + 'name_id_encryption_key', +] as const; + +const OIDC_CLIENT_ID_KEYS = ['client_id', 'clientId'] as const; +const OIDC_CLIENT_SECRET_KEYS = ['client_secret', 'clientSecret'] as const; +const OIDC_DISCOVERY_KEYS = [ + 'discoveryEndpoint', + 'discovery_endpoint', + 'discoveryUrl', + 'discovery_url', + 'issuer', + 'issuerUrl', + 'issuer_url', +] as const; +const OIDC_REDIRECT_URI_KEYS = [ + 'redirectUri', + 'redirect_uri', + 'callbackUrl', + 'callbackURL', +] as const; + +const ATTRIBUTE_MAPPING_KEYS = [ + 'fieldsMap', + 'fieldMap', + 'fields_map', + 'mapping', + 'attributeMap', + 'attribute_map', + 'attributes', + 'profileMap', + 'profile_map', +] as const; + +const COMMON_PROFILE_ATTRIBUTES = new Set([ + 'email', + 'given_name', + 'family_name', + 'first_name', + 'last_name', + 'name', + 'nickname', + 'picture', + 'user_id', + 'sub', +]); + +const REDACTED_VALUE = '[REDACTED]'; + +type UnknownRecord = Record; + +export function classifyAuth0ConnectionProtocol( + connection: Auth0Connection, +): Auth0SsoClassification { + const strategy = connection.strategy.toLowerCase(); + if (strategy === 'samlp') return 'saml'; + if (strategy === 'oidc') return 'oidc'; + return 'unsupported'; +} + +export function buildAuth0ConnectionImportedId(connection: Auth0Connection): string { + return `auth0:${connection.id}`; +} + +export function mapAuth0ConnectionToSsoHandoff( + input: Auth0SsoMappingInput, +): Auth0SsoConnectionMapping { + const { connection } = input; + const importedId = buildAuth0ConnectionImportedId(connection); + const protocol = classifyAuth0ConnectionProtocol(connection); + + if (protocol === 'unsupported') { + const warning = unsupportedConnectionProtocolWarning({ + provider: 'auth0', + protocol: connection.strategy || 'unknown', + importedId, + strategy: connection.strategy, + reason: + 'Only Auth0 samlp and oidc enterprise connections are supported for WorkOS SSO handoff.', + }); + return { + status: 'skipped', + protocol, + importedId, + reason: 'unsupported_connection_protocol', + warnings: [warning], + }; + } + + if (protocol === 'saml') { + return mapSamlConnection(input, importedId); + } + + return mapOidcConnection(input, importedId); +} + +export function redactAuth0ConnectionSecrets(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactAuth0ConnectionSecrets(item)); + } + + if (!isRecord(value)) { + return value; + } + + const redacted: UnknownRecord = {}; + for (const [key, nestedValue] of Object.entries(value)) { + redacted[key] = shouldRedactKey(key) + ? REDACTED_VALUE + : redactAuth0ConnectionSecrets(nestedValue); + } + return redacted; +} + +function mapSamlConnection( + input: Auth0SsoMappingInput, + importedId: string, +): Auth0SsoConnectionMapping { + const { connection } = input; + const options = recordValue(connection.options); + const metadataXml = getFirstString(options, SAML_XML_OPTION_KEYS); + const parsedMetadata = parseSamlMetadata(metadataXml); + const idpMetadataUrl = getFirstString(options, SAML_METADATA_URL_KEYS); + const idpEntityId = firstNonEmpty( + getFirstString(options, SAML_IDP_ENTITY_ID_KEYS), + parsedMetadata.entityId, + ); + const idpUrl = firstNonEmpty( + getFirstString(options, SAML_IDP_URL_KEYS), + parsedMetadata.ssoRedirectUrl, + ); + const x509Cert = firstNonEmpty(getFirstString(options, SAML_CERT_KEYS), parsedMetadata.x509Cert); + const missingFields = missingSamlFields({ idpEntityId, idpUrl, x509Cert, idpMetadataUrl }); + + if (missingFields.length > 0) { + const warning = incompleteConnectionConfigurationWarning({ + provider: 'auth0', + protocol: 'saml', + importedId, + strategy: connection.strategy, + missingFields, + reason: + 'SAML handoff requires IdP metadata URL or the entity ID, SSO URL, and signing certificate.', + }); + return { + status: 'skipped', + protocol: 'saml', + importedId, + reason: 'incomplete_connection_configuration', + warnings: [warning], + }; + } + + const organization = buildOrganizationContext( + connection, + input.orgBindings ?? [], + 'saml', + importedId, + ); + const attributeMappings = extractAttributeMappings(connection); + const customAcsUrl = getFirstString(options, SAML_ACS_URL_KEYS); + const customEntityId = getFirstString(options, SAML_SP_ENTITY_ID_KEYS); + const sourceAcsUrl = customAcsUrl || buildAuth0CallbackUrl(input.domain, connection.name); + const samlSecretValues = getSecretValues(options, SAML_SECRET_KEYS); + const warnings = [...organization.warnings]; + + if (!input.includeSecrets && samlSecretValues.length > 0) { + warnings.push( + redactedSecretsWarning({ + provider: 'auth0', + protocol: 'saml', + importedId, + fields: samlSecretValues, + file: 'sso/saml_connections.csv', + }), + ); + } + + const row = createSamlConnectionRow({ + organizationName: organization.organizationName, + organizationExternalId: organization.organizationExternalId, + domains: organization.domains.join(','), + idpEntityId, + idpUrl, + x509Cert, + idpMetadataUrl, + customEntityId, + customAcsUrl, + idpIdAttribute: lookupMapping(attributeMappings, ['user_id', 'sub']), + emailAttribute: lookupMapping(attributeMappings, ['email']), + firstNameAttribute: lookupMapping(attributeMappings, ['given_name', 'first_name']), + lastNameAttribute: lookupMapping(attributeMappings, ['family_name', 'last_name']), + name: lookupMapping(attributeMappings, ['name']), + customAttributes: buildCustomAttributesJson(attributeMappings), + idpInitiatedEnabled: boolishString(getOptionValue(options, ['idpinitiated', 'idpInitiated'])), + requestSigningKey: input.includeSecrets + ? getFirstString(options, ['requestSigningKey', 'request_signing_key']) + : '', + assertionEncryptionKey: input.includeSecrets + ? getFirstString(options, ['assertionEncryptionKey', 'assertion_encryption_key']) + : '', + nameIdEncryptionKey: input.includeSecrets + ? getFirstString(options, ['nameIdEncryptionKey', 'name_id_encryption_key']) + : '', + importedId, + }); + + return { + status: 'mapped', + protocol: 'saml', + importedId, + samlRow: row, + customAttributeRows: toCustomAttributeRows(attributeMappings, importedId, organization, 'SAML'), + proxyRouteRow: createProxyRouteRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + provider: 'auth0', + protocol: 'saml', + sourceAcsUrl, + sourceEntityId: customEntityId, + customAcsUrl, + customEntityId, + cutoverState: 'legacy', + notes: 'Existing Auth0 SAML ACS route should be proxied until the IdP is updated to WorkOS.', + }), + warnings, + }; +} + +function mapOidcConnection( + input: Auth0SsoMappingInput, + importedId: string, +): Auth0SsoConnectionMapping { + const { connection } = input; + const options = recordValue(connection.options); + const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); + const clientSecret = getFirstString(options, OIDC_CLIENT_SECRET_KEYS); + const discoveryEndpoint = normalizeDiscoveryEndpoint( + getFirstString(options, OIDC_DISCOVERY_KEYS), + ); + const missingFields = ['clientId', 'discoveryEndpoint'].filter((field) => { + if (field === 'clientId') return !clientId; + return !discoveryEndpoint; + }); + + if (input.includeSecrets && !clientSecret) { + missingFields.push('clientSecret'); + } + + if (missingFields.length > 0) { + const warning = incompleteConnectionConfigurationWarning({ + provider: 'auth0', + protocol: 'oidc', + importedId, + strategy: connection.strategy, + missingFields, + reason: 'OIDC handoff requires a client ID and discovery endpoint.', + }); + return { + status: 'skipped', + protocol: 'oidc', + importedId, + reason: 'incomplete_connection_configuration', + warnings: [warning], + }; + } + + const organization = buildOrganizationContext( + connection, + input.orgBindings ?? [], + 'oidc', + importedId, + ); + const attributeMappings = extractAttributeMappings(connection); + const customRedirectUri = getFirstString(options, OIDC_REDIRECT_URI_KEYS); + const sourceRedirectUri = + customRedirectUri || buildAuth0CallbackUrl(input.domain, connection.name); + const warnings = [...organization.warnings]; + + if (!input.includeSecrets && clientSecret) { + warnings.push( + redactedSecretsWarning({ + provider: 'auth0', + protocol: 'oidc', + importedId, + fields: ['clientSecret'], + file: 'sso/oidc_connections.csv', + }), + ); + } + + const row = createOidcConnectionRow({ + organizationName: organization.organizationName, + organizationExternalId: organization.organizationExternalId, + domains: organization.domains.join(','), + clientId, + clientSecret: input.includeSecrets ? clientSecret : '', + discoveryEndpoint: discoveryEndpoint ?? '', + customRedirectUri, + name: lookupMapping(attributeMappings, ['name']), + customAttributes: buildCustomAttributesJson(attributeMappings), + importedId, + }); + + return { + status: 'mapped', + protocol: 'oidc', + importedId, + oidcRow: row, + customAttributeRows: toCustomAttributeRows(attributeMappings, importedId, organization, 'OIDC'), + proxyRouteRow: createProxyRouteRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + provider: 'auth0', + protocol: 'oidc', + sourceRedirectUri, + customRedirectUri, + cutoverState: 'legacy', + notes: + 'Existing Auth0 OIDC redirect route should be proxied until the IdP is updated to WorkOS.', + }), + warnings, + }; +} + +function missingSamlFields(input: { + idpEntityId: string; + idpUrl: string; + x509Cert: string; + idpMetadataUrl: string; +}): string[] { + if (input.idpMetadataUrl) return []; + + const missing: string[] = []; + if (!input.idpEntityId) missing.push('idpEntityId'); + if (!input.idpUrl) missing.push('idpUrl'); + if (!input.x509Cert) missing.push('x509Cert'); + return missing; +} + +function buildOrganizationContext( + connection: Auth0Connection, + orgBindings: Auth0SsoConnectionOrgBinding[], + protocol: Auth0SsoProtocol, + importedId: string, +): { + organizationName: string; + organizationExternalId: string; + domains: string[]; + warnings: SsoHandoffWarning[]; +} { + const warnings: SsoHandoffWarning[] = []; + const connectionName = connection.display_name || connection.name; + + if (orgBindings.length === 1) { + const org = orgBindings[0].organization; + const domains = extractDomains(org.metadata); + const context = { + organizationName: org.display_name || org.name, + organizationExternalId: org.id, + domains, + warnings, + }; + addMissingDomainWarning(context, protocol, importedId); + return context; + } + + if (orgBindings.length > 1) { + const domains = uniqueDomains( + orgBindings.flatMap((binding) => extractDomains(binding.organization.metadata)), + ); + const organizationExternalId = connection.id; + const context = { + organizationName: connectionName, + organizationExternalId, + domains, + warnings, + }; + warnings.push( + multiOrgConnectionConsolidationWarning({ + provider: 'auth0', + protocol, + importedId, + organizationExternalId, + sourceOrganizationIds: orgBindings.map((binding) => binding.organization.id), + domains, + }), + ); + addMissingDomainWarning(context, protocol, importedId); + return context; + } + + const domains = uniqueDomains([ + ...extractDomains(connection.metadata), + ...extractDomains(recordValue(connection.options)), + ]); + const context = { + organizationName: connectionName, + organizationExternalId: connection.id, + domains, + warnings, + }; + addMissingDomainWarning(context, protocol, importedId); + return context; +} + +function addMissingDomainWarning( + context: { + organizationExternalId: string; + organizationName: string; + domains: string[]; + warnings: SsoHandoffWarning[]; + }, + protocol: Auth0SsoProtocol, + importedId: string, +): void { + if (context.domains.length > 0) return; + context.warnings.push( + missingDomainsWarning({ + provider: 'auth0', + protocol, + importedId, + organizationExternalId: context.organizationExternalId, + organizationName: context.organizationName, + }), + ); +} + +function extractAttributeMappings(connection: Auth0Connection): Record { + const options = recordValue(connection.options); + const mappings: Record = {}; + + for (const key of ATTRIBUTE_MAPPING_KEYS) { + const candidate = getOptionValue(options, [key]) ?? connection[key]; + if (!isRecord(candidate)) continue; + + for (const [attribute, claim] of Object.entries(candidate)) { + const stringClaim = stringValue(claim); + if (!stringClaim) continue; + mappings[attribute] = stringClaim; + } + } + + return mappings; +} + +function toCustomAttributeRows( + attributeMappings: Record, + importedId: string, + organization: { organizationExternalId: string }, + providerType: 'SAML' | 'OIDC', +): CustomAttrRow[] { + return Object.entries(attributeMappings) + .filter(([attribute, claim]) => Boolean(claim) && !COMMON_PROFILE_ATTRIBUTES.has(attribute)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([attribute, claim]) => + createCustomAttributeMappingRow({ + importedId, + organizationExternalId: organization.organizationExternalId, + providerType, + userPoolAttribute: attribute, + idpClaim: claim, + }), + ); +} + +function buildCustomAttributesJson(attributeMappings: Record): string { + const customMappings = Object.fromEntries( + Object.entries(attributeMappings) + .filter(([attribute, claim]) => Boolean(claim) && !COMMON_PROFILE_ATTRIBUTES.has(attribute)) + .sort(([a], [b]) => a.localeCompare(b)), + ); + + return Object.keys(customMappings).length > 0 ? JSON.stringify(customMappings) : ''; +} + +function lookupMapping(attributeMappings: Record, keys: string[]): string { + for (const key of keys) { + const value = attributeMappings[key]; + if (value) return value; + } + return ''; +} + +function extractDomains(source: unknown): string[] { + if (!isRecord(source)) return []; + + const values = [ + source.domains, + source.domain, + source.domain_aliases, + source.domainAliases, + source.email_domains, + source.emailDomains, + ]; + + return uniqueDomains(values.flatMap((value) => parseDomainValue(value))); +} + +function parseDomainValue(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap((item) => parseDomainValue(item)); + } + + const stringDomain = stringValue(value); + if (!stringDomain) return []; + + return stringDomain + .split(/[;,\s]+/) + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean); +} + +function uniqueDomains(domains: string[]): string[] { + return [...new Set(domains.map((domain) => domain.trim().toLowerCase()).filter(Boolean))].sort(); +} + +function getSecretValues(options: UnknownRecord, keys: readonly string[]): string[] { + return keys.filter((key) => Boolean(getFirstString(options, [key]))); +} + +function getFirstString(record: UnknownRecord, keys: readonly string[]): string { + for (const key of keys) { + const value = stringValue(getOptionValue(record, [key])); + if (value) return value; + } + return ''; +} + +function getOptionValue(record: UnknownRecord, keys: readonly string[]): unknown { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + return record[key]; + } + } + return undefined; +} + +function firstNonEmpty(...values: Array): string { + for (const value of values) { + const normalized = stringValue(value); + if (normalized) return normalized; + } + return ''; +} + +function stringValue(value: unknown): string { + if (typeof value !== 'string') return ''; + return value.trim(); +} + +function recordValue(value: unknown): UnknownRecord { + return isRecord(value) ? value : {}; +} + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function shouldRedactKey(key: string): boolean { + const normalized = key.replace(/[-_\s]/g, '').toLowerCase(); + return ( + normalized === 'secret' || + normalized.endsWith('secret') || + normalized === 'password' || + normalized.endsWith('password') || + normalized.endsWith('privatekey') || + normalized === 'requestsigningkey' || + normalized === 'assertionencryptionkey' || + normalized === 'nameidencryptionkey' || + normalized === 'accesstoken' || + normalized === 'refreshtoken' || + normalized === 'idtoken' + ); +} + +function boolishString(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'; + return stringValue(value); +} + +function buildAuth0CallbackUrl(domain: string, connectionName: string): string { + return `https://${domain}/login/callback?connection=${encodeURIComponent(connectionName)}`; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 55b4df0..bb9ef72 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -108,6 +108,8 @@ export interface Auth0ExportOptions { output?: string; package?: boolean; outputDir?: string; + entities?: string[]; + includeSecrets?: boolean; orgs?: string[]; pageSize: number; rateLimit: number; diff --git a/src/sso/__tests__/handoff.test.ts b/src/sso/__tests__/handoff.test.ts index 8ead3c3..3a46ab3 100644 --- a/src/sso/__tests__/handoff.test.ts +++ b/src/sso/__tests__/handoff.test.ts @@ -10,6 +10,7 @@ import { createOidcConnectionRow, createProxyRouteRow, createSamlConnectionRow, + incompleteConnectionConfigurationWarning, missingDomainsWarning, multiOrgConnectionConsolidationWarning, redactedSecretsWarning, @@ -198,5 +199,21 @@ describe('SSO handoff warning helpers', () => { strategy: 'google-oauth2', }, }); + + expect( + incompleteConnectionConfigurationWarning({ + provider: 'auth0', + protocol: 'saml', + importedId: 'auth0:con_incomplete', + strategy: 'samlp', + missingFields: ['idpEntityId', 'x509Cert'], + }), + ).toMatchObject({ + code: 'incomplete_connection_configuration', + details: { + strategy: 'samlp', + missingFields: ['idpEntityId', 'x509Cert'], + }, + }); }); }); diff --git a/src/sso/handoff.ts b/src/sso/handoff.ts index b6f70ee..2a9017a 100644 --- a/src/sso/handoff.ts +++ b/src/sso/handoff.ts @@ -118,7 +118,8 @@ export type SsoWarningCode = | 'missing_domains' | 'secrets_redacted' | 'multi_org_connection_consolidated' - | 'unsupported_connection_protocol'; + | 'unsupported_connection_protocol' + | 'incomplete_connection_configuration'; export interface SsoHandoffWarning { code: SsoWarningCode; @@ -212,6 +213,28 @@ export function unsupportedConnectionProtocolWarning(input: { }; } +export function incompleteConnectionConfigurationWarning(input: { + provider: string; + protocol: string; + importedId?: string; + strategy?: string; + missingFields: string[]; + reason?: string; +}): SsoHandoffWarning { + return { + code: 'incomplete_connection_configuration', + provider: input.provider, + protocol: input.protocol, + importedId: input.importedId, + message: `${input.provider} ${input.protocol} connection${input.importedId ? ` ${input.importedId}` : ''} was skipped because required handoff configuration was not available.`, + details: { + strategy: input.strategy, + missingFields: input.missingFields, + reason: input.reason, + }, + }; +} + function createRow( headers: THeaders, input: Record, From 927c26aef8280b005f957a32a3251b4792e3f513 Mon Sep 17 00:00:00 2001 From: Zac Burrage Date: Fri, 1 May 2026 17:27:20 -0500 Subject: [PATCH 2/2] fix: infer auth0 enterprise sso protocols --- README.md | 2 +- dist/exporters/auth0/package-exporter.js | 2 +- dist/exporters/auth0/sso-mapper.d.ts | 1 + dist/exporters/auth0/sso-mapper.js | 90 ++++++++++- .../auth0/__tests__/sso-mapper.test.ts | 142 ++++++++++++++++++ src/exporters/auth0/package-exporter.ts | 2 +- src/exporters/auth0/sso-mapper.ts | 107 ++++++++++++- 7 files changed, 336 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3f985b5..40d0306 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Options: - `--resume [jobId]` - Resume a previously checkpointed export The export maps Auth0 fields to WorkOS CSV format, including `email_verified`, `external_id`, and custom metadata. -Auth0 package SSO export is handoff-only: it emits only SAML and OIDC enterprise connections with enough configuration for WorkOS handoff. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings. +Auth0 package SSO export is handoff-only: it inspects Auth0 enterprise strategies for SAML/OIDC configuration and emits only connections with enough reliable handoff data. Database, passwordless, social, generic OAuth, non-SAML/OIDC enterprise, and incomplete connections are skipped with warnings. For a callback proxy reference implementation during Auth0 enterprise-connection cutover, see [`proxy-sample-auth0`](proxy-sample-auth0/README.md). The repo also includes [`proxy-sample-cognito`](proxy-sample-cognito/README.md) for Cognito migrations. diff --git a/dist/exporters/auth0/package-exporter.js b/dist/exporters/auth0/package-exporter.js index 7c4c090..4fb57e0 100644 --- a/dist/exporters/auth0/package-exporter.js +++ b/dist/exporters/auth0/package-exporter.js @@ -608,7 +608,7 @@ function buildHandoffNotes(input) { '# Auth0 SSO handoff notes', '', 'Auth0 SSO export is handoff-only. The package writes SAML and OIDC connection CSVs for WorkOS/manual processing and does not create WorkOS SSO connections automatically.', - 'Only Auth0 `samlp` and `oidc` enterprise connections with enough configuration are emitted. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings.', + 'Auth0 enterprise strategies are inspected for SAML/OIDC configuration, and only connections with enough reliable handoff data are emitted. Database, passwordless, social, generic OAuth, non-SAML/OIDC enterprise, and incomplete connections are skipped with warnings.', 'If one Auth0 connection is enabled for multiple Auth0 organizations, the exporter writes one handoff row with the union of source organization domains and a confirmation warning.', input.includeSecrets ? 'Connection secrets were included because --include-secrets was set.' diff --git a/dist/exporters/auth0/sso-mapper.d.ts b/dist/exporters/auth0/sso-mapper.d.ts index 2108f9f..3bb7265 100644 --- a/dist/exporters/auth0/sso-mapper.d.ts +++ b/dist/exporters/auth0/sso-mapper.d.ts @@ -29,6 +29,7 @@ export type Auth0SsoConnectionMapping = { warnings: SsoHandoffWarning[]; }; export declare const AUTH0_REDACTED_SECRET_FIELDS: readonly ["client_secret", "clientSecret", "secret", "password", "private_key", "privateKey", "requestSigningKey", "assertionEncryptionKey", "nameIdEncryptionKey", "access_token", "refresh_token", "id_token"]; +export declare const AUTH0_ENTERPRISE_SSO_STRATEGIES: readonly ["ad", "adfs", "auth0-adldap", "google-apps", "ip", "office365", "oidc", "okta", "pingfederate", "samlp", "sharepoint", "waad"]; export declare function classifyAuth0ConnectionProtocol(connection: Auth0Connection): Auth0SsoClassification; export declare function buildAuth0ConnectionImportedId(connection: Auth0Connection): string; export declare function mapAuth0ConnectionToSsoHandoff(input: Auth0SsoMappingInput): Auth0SsoConnectionMapping; diff --git a/dist/exporters/auth0/sso-mapper.js b/dist/exporters/auth0/sso-mapper.js index b0b3d95..1f49658 100644 --- a/dist/exporters/auth0/sso-mapper.js +++ b/dist/exporters/auth0/sso-mapper.js @@ -14,6 +14,20 @@ export const AUTH0_REDACTED_SECRET_FIELDS = [ 'refresh_token', 'id_token', ]; +export const AUTH0_ENTERPRISE_SSO_STRATEGIES = [ + 'ad', + 'adfs', + 'auth0-adldap', + 'google-apps', + 'ip', + 'office365', + 'oidc', + 'okta', + 'pingfederate', + 'samlp', + 'sharepoint', + 'waad', +]; const SAML_XML_OPTION_KEYS = [ 'metadataXml', 'metadataXML', @@ -23,6 +37,9 @@ const SAML_XML_OPTION_KEYS = [ 'idp_metadata_xml', ]; const SAML_METADATA_URL_KEYS = [ + 'federationMetadataUrl', + 'federation_metadata_url', + 'FederationMetadataUrl', 'metadataUrl', 'metadataURL', 'metadata_url', @@ -90,6 +107,20 @@ const OIDC_DISCOVERY_KEYS = [ 'issuerUrl', 'issuer_url', ]; +const OIDC_ISSUER_DOMAIN_KEYS = [ + 'domain', + 'issuerDomain', + 'issuer_domain', + 'oktaDomain', + 'okta_domain', +]; +const AZURE_TENANT_KEYS = [ + 'tenant_domain', + 'tenantDomain', + 'tenant_id', + 'tenantId', + 'domain', +]; const OIDC_REDIRECT_URI_KEYS = [ 'redirectUri', 'redirect_uri', @@ -120,12 +151,21 @@ const COMMON_PROFILE_ATTRIBUTES = new Set([ 'sub', ]); const REDACTED_VALUE = '[REDACTED]'; +const ENTERPRISE_SSO_STRATEGIES = new Set(AUTH0_ENTERPRISE_SSO_STRATEGIES); +const AZURE_OIDC_STRATEGIES = new Set(['waad', 'office365']); export function classifyAuth0ConnectionProtocol(connection) { const strategy = connection.strategy.toLowerCase(); if (strategy === 'samlp') return 'saml'; if (strategy === 'oidc') return 'oidc'; + if (!ENTERPRISE_SSO_STRATEGIES.has(strategy)) + return 'unsupported'; + const options = recordValue(connection.options); + if (hasOidcConnectionData(strategy, options)) + return 'oidc'; + if (hasSamlConnectionData(options)) + return 'saml'; return 'unsupported'; } export function buildAuth0ConnectionImportedId(connection) { @@ -136,12 +176,16 @@ export function mapAuth0ConnectionToSsoHandoff(input) { const importedId = buildAuth0ConnectionImportedId(connection); const protocol = classifyAuth0ConnectionProtocol(connection); if (protocol === 'unsupported') { + const strategy = connection.strategy.toLowerCase(); + const reason = ENTERPRISE_SSO_STRATEGIES.has(strategy) + ? 'Auth0 enterprise strategy did not expose enough SAML or OIDC handoff configuration.' + : 'Only Auth0 enterprise connections with SAML or OIDC configuration are supported for WorkOS SSO handoff.'; const warning = unsupportedConnectionProtocolWarning({ provider: 'auth0', protocol: connection.strategy || 'unknown', importedId, strategy: connection.strategy, - reason: 'Only Auth0 samlp and oidc enterprise connections are supported for WorkOS SSO handoff.', + reason, }); return { status: 'skipped', @@ -268,7 +312,7 @@ function mapOidcConnection(input, importedId) { const options = recordValue(connection.options); const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); const clientSecret = getFirstString(options, OIDC_CLIENT_SECRET_KEYS); - const discoveryEndpoint = normalizeDiscoveryEndpoint(getFirstString(options, OIDC_DISCOVERY_KEYS)); + const discoveryEndpoint = buildOidcDiscoveryEndpoint(connection.strategy, options, clientId); const missingFields = ['clientId', 'discoveryEndpoint'].filter((field) => { if (field === 'clientId') return !clientId; @@ -351,6 +395,43 @@ function missingSamlFields(input) { missing.push('x509Cert'); return missing; } +function hasOidcConnectionData(strategy, options) { + const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); + return Boolean(clientId && buildOidcDiscoveryEndpoint(strategy, options, clientId)); +} +function buildOidcDiscoveryEndpoint(strategy, options, clientId) { + const direct = normalizeDiscoveryEndpoint(getFirstString(options, OIDC_DISCOVERY_KEYS)); + if (direct) + return direct; + const normalizedStrategy = strategy.toLowerCase(); + if (normalizedStrategy === 'google-apps' && clientId) { + return 'https://accounts.google.com/.well-known/openid-configuration'; + } + if (AZURE_OIDC_STRATEGIES.has(normalizedStrategy) && clientId) { + const tenant = getFirstString(options, AZURE_TENANT_KEYS); + if (tenant) { + return `https://login.microsoftonline.com/${encodeURIComponent(tenant)}/v2.0/.well-known/openid-configuration`; + } + } + if (normalizedStrategy === 'okta' && clientId) { + const domain = getFirstString(options, OIDC_ISSUER_DOMAIN_KEYS); + if (domain) { + return normalizeDiscoveryEndpoint(ensureHttps(domain)); + } + } + return null; +} +function hasSamlConnectionData(options) { + const metadataXml = getFirstString(options, SAML_XML_OPTION_KEYS); + const parsedMetadata = parseSamlMetadata(metadataXml); + return Boolean(parsedMetadata.entityId || + parsedMetadata.ssoRedirectUrl || + parsedMetadata.x509Cert || + getFirstString(options, SAML_METADATA_URL_KEYS) || + getFirstString(options, SAML_IDP_ENTITY_ID_KEYS) || + getFirstString(options, SAML_IDP_URL_KEYS) || + getFirstString(options, SAML_CERT_KEYS)); +} function buildOrganizationContext(connection, orgBindings, protocol, importedId) { const warnings = []; const connectionName = connection.display_name || connection.name; @@ -539,6 +620,11 @@ function boolishString(value) { return value ? 'TRUE' : 'FALSE'; return stringValue(value); } +function ensureHttps(value) { + if (/^https?:\/\//i.test(value)) + return value; + return `https://${value}`; +} function buildAuth0CallbackUrl(domain, connectionName) { return `https://${domain}/login/callback?connection=${encodeURIComponent(connectionName)}`; } diff --git a/src/exporters/auth0/__tests__/sso-mapper.test.ts b/src/exporters/auth0/__tests__/sso-mapper.test.ts index 577324b..d890ff5 100644 --- a/src/exporters/auth0/__tests__/sso-mapper.test.ts +++ b/src/exporters/auth0/__tests__/sso-mapper.test.ts @@ -1,5 +1,6 @@ import type { Auth0Connection, Auth0Organization } from '../../../shared/types'; import { + AUTH0_ENTERPRISE_SSO_STRATEGIES, classifyAuth0ConnectionProtocol, mapAuth0ConnectionToSsoHandoff, redactAuth0ConnectionSecrets, @@ -15,6 +16,22 @@ const org: Auth0Organization = { }; describe('Auth0 SSO handoff mapper', () => { + it('tracks Auth0 enterprise strategy values as SSO candidates', () => { + expect(AUTH0_ENTERPRISE_SSO_STRATEGIES).toEqual( + expect.arrayContaining([ + 'ad', + 'adfs', + 'auth0-adldap', + 'google-apps', + 'oidc', + 'okta', + 'pingfederate', + 'samlp', + 'waad', + ]), + ); + }); + it('maps complete SAML enterprise connections into handoff rows', () => { const connection: Auth0Connection = { id: 'con_saml', @@ -117,6 +134,97 @@ describe('Auth0 SSO handoff mapper', () => { ]); }); + it('maps SAML-capable enterprise strategies when SAML options are present', () => { + const connection: Auth0Connection = { + id: 'con_okta_saml', + name: 'okta-saml', + strategy: 'okta', + options: { + metadataUrl: 'https://okta.example.com/app/metadata', + entityId: 'https://okta.example.com/entity', + signInEndpoint: 'https://okta.example.com/sso/saml', + signingCert: 'CERTDATA', + }, + }; + + expect(classifyAuth0ConnectionProtocol(connection)).toBe('saml'); + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }], + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.protocol).toBe('saml'); + expect(result.samlRow).toMatchObject({ + idpMetadataUrl: 'https://okta.example.com/app/metadata', + idpEntityId: 'https://okta.example.com/entity', + idpUrl: 'https://okta.example.com/sso/saml', + x509Cert: 'CERTDATA', + importedId: 'auth0:con_okta_saml', + }); + }); + + it('maps Azure AD enterprise strategy to OIDC when tenant and client data are present', () => { + const connection: Auth0Connection = { + id: 'con_waad', + name: 'azure-ad', + strategy: 'waad', + options: { + client_id: 'azure-client', + client_secret: 'azure-secret', + tenant_domain: 'contoso.onmicrosoft.com', + }, + }; + + expect(classifyAuth0ConnectionProtocol(connection)).toBe('oidc'); + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }], + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.oidcRow).toMatchObject({ + clientId: 'azure-client', + discoveryEndpoint: + 'https://login.microsoftonline.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration', + importedId: 'auth0:con_waad', + }); + }); + + it('maps Google Workspace enterprise strategy to OIDC when client data is present', () => { + const connection: Auth0Connection = { + id: 'con_google_apps', + name: 'google-workspace', + strategy: 'google-apps', + options: { + client_id: 'google-client', + client_secret: 'google-secret', + }, + }; + + expect(classifyAuth0ConnectionProtocol(connection)).toBe('oidc'); + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + orgBindings: [{ organization: org }], + }); + + expect(result.status).toBe('mapped'); + if (result.status !== 'mapped') return; + expect(result.oidcRow).toMatchObject({ + clientId: 'google-client', + discoveryEndpoint: 'https://accounts.google.com/.well-known/openid-configuration', + importedId: 'auth0:con_google_apps', + }); + }); + it('skips unsupported Auth0 connection strategies', () => { const connection: Auth0Connection = { id: 'con_db', @@ -144,6 +252,40 @@ describe('Auth0 SSO handoff mapper', () => { }); }); + it('skips enterprise strategies when SAML/OIDC handoff data is absent', () => { + const connection: Auth0Connection = { + id: 'con_adldap', + name: 'corp-ad', + strategy: 'auth0-adldap', + options: { + domain: 'corp.example.com', + }, + }; + + expect(classifyAuth0ConnectionProtocol(connection)).toBe('unsupported'); + + const result = mapAuth0ConnectionToSsoHandoff({ + connection, + domain: 'tenant.auth0.com', + }); + + expect(result).toMatchObject({ + status: 'skipped', + protocol: 'unsupported', + reason: 'unsupported_connection_protocol', + warnings: [ + { + code: 'unsupported_connection_protocol', + importedId: 'auth0:con_adldap', + details: { + reason: + 'Auth0 enterprise strategy did not expose enough SAML or OIDC handoff configuration.', + }, + }, + ], + }); + }); + it('skips SAML connections missing required handoff configuration', () => { const connection: Auth0Connection = { id: 'con_incomplete', diff --git a/src/exporters/auth0/package-exporter.ts b/src/exporters/auth0/package-exporter.ts index bee6a61..74eeca9 100644 --- a/src/exporters/auth0/package-exporter.ts +++ b/src/exporters/auth0/package-exporter.ts @@ -935,7 +935,7 @@ function buildHandoffNotes(input: { includeSso: boolean; includeSecrets: boolean '# Auth0 SSO handoff notes', '', 'Auth0 SSO export is handoff-only. The package writes SAML and OIDC connection CSVs for WorkOS/manual processing and does not create WorkOS SSO connections automatically.', - 'Only Auth0 `samlp` and `oidc` enterprise connections with enough configuration are emitted. Database, passwordless, social, generic OAuth, and incomplete connections are skipped with warnings.', + 'Auth0 enterprise strategies are inspected for SAML/OIDC configuration, and only connections with enough reliable handoff data are emitted. Database, passwordless, social, generic OAuth, non-SAML/OIDC enterprise, and incomplete connections are skipped with warnings.', 'If one Auth0 connection is enabled for multiple Auth0 organizations, the exporter writes one handoff row with the union of source organization domains and a confirmation warning.', input.includeSecrets ? 'Connection secrets were included because --include-secrets was set.' diff --git a/src/exporters/auth0/sso-mapper.ts b/src/exporters/auth0/sso-mapper.ts index b78b761..11784c0 100644 --- a/src/exporters/auth0/sso-mapper.ts +++ b/src/exporters/auth0/sso-mapper.ts @@ -70,6 +70,21 @@ export const AUTH0_REDACTED_SECRET_FIELDS = [ 'id_token', ] as const; +export const AUTH0_ENTERPRISE_SSO_STRATEGIES = [ + 'ad', + 'adfs', + 'auth0-adldap', + 'google-apps', + 'ip', + 'office365', + 'oidc', + 'okta', + 'pingfederate', + 'samlp', + 'sharepoint', + 'waad', +] as const; + const SAML_XML_OPTION_KEYS = [ 'metadataXml', 'metadataXML', @@ -80,6 +95,9 @@ const SAML_XML_OPTION_KEYS = [ ] as const; const SAML_METADATA_URL_KEYS = [ + 'federationMetadataUrl', + 'federation_metadata_url', + 'FederationMetadataUrl', 'metadataUrl', 'metadataURL', 'metadata_url', @@ -154,6 +172,20 @@ const OIDC_DISCOVERY_KEYS = [ 'issuerUrl', 'issuer_url', ] as const; +const OIDC_ISSUER_DOMAIN_KEYS = [ + 'domain', + 'issuerDomain', + 'issuer_domain', + 'oktaDomain', + 'okta_domain', +] as const; +const AZURE_TENANT_KEYS = [ + 'tenant_domain', + 'tenantDomain', + 'tenant_id', + 'tenantId', + 'domain', +] as const; const OIDC_REDIRECT_URI_KEYS = [ 'redirectUri', 'redirect_uri', @@ -187,6 +219,8 @@ const COMMON_PROFILE_ATTRIBUTES = new Set([ ]); const REDACTED_VALUE = '[REDACTED]'; +const ENTERPRISE_SSO_STRATEGIES = new Set(AUTH0_ENTERPRISE_SSO_STRATEGIES); +const AZURE_OIDC_STRATEGIES = new Set(['waad', 'office365']); type UnknownRecord = Record; @@ -196,6 +230,12 @@ export function classifyAuth0ConnectionProtocol( const strategy = connection.strategy.toLowerCase(); if (strategy === 'samlp') return 'saml'; if (strategy === 'oidc') return 'oidc'; + if (!ENTERPRISE_SSO_STRATEGIES.has(strategy)) return 'unsupported'; + + const options = recordValue(connection.options); + if (hasOidcConnectionData(strategy, options)) return 'oidc'; + if (hasSamlConnectionData(options)) return 'saml'; + return 'unsupported'; } @@ -211,13 +251,16 @@ export function mapAuth0ConnectionToSsoHandoff( const protocol = classifyAuth0ConnectionProtocol(connection); if (protocol === 'unsupported') { + const strategy = connection.strategy.toLowerCase(); + const reason = ENTERPRISE_SSO_STRATEGIES.has(strategy) + ? 'Auth0 enterprise strategy did not expose enough SAML or OIDC handoff configuration.' + : 'Only Auth0 enterprise connections with SAML or OIDC configuration are supported for WorkOS SSO handoff.'; const warning = unsupportedConnectionProtocolWarning({ provider: 'auth0', protocol: connection.strategy || 'unknown', importedId, strategy: connection.strategy, - reason: - 'Only Auth0 samlp and oidc enterprise connections are supported for WorkOS SSO handoff.', + reason, }); return { status: 'skipped', @@ -376,9 +419,7 @@ function mapOidcConnection( const options = recordValue(connection.options); const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); const clientSecret = getFirstString(options, OIDC_CLIENT_SECRET_KEYS); - const discoveryEndpoint = normalizeDiscoveryEndpoint( - getFirstString(options, OIDC_DISCOVERY_KEYS), - ); + const discoveryEndpoint = buildOidcDiscoveryEndpoint(connection.strategy, options, clientId); const missingFields = ['clientId', 'discoveryEndpoint'].filter((field) => { if (field === 'clientId') return !clientId; return !discoveryEndpoint; @@ -479,6 +520,57 @@ function missingSamlFields(input: { return missing; } +function hasOidcConnectionData(strategy: string, options: UnknownRecord): boolean { + const clientId = getFirstString(options, OIDC_CLIENT_ID_KEYS); + return Boolean(clientId && buildOidcDiscoveryEndpoint(strategy, options, clientId)); +} + +function buildOidcDiscoveryEndpoint( + strategy: string, + options: UnknownRecord, + clientId: string, +): string | null { + const direct = normalizeDiscoveryEndpoint(getFirstString(options, OIDC_DISCOVERY_KEYS)); + if (direct) return direct; + + const normalizedStrategy = strategy.toLowerCase(); + if (normalizedStrategy === 'google-apps' && clientId) { + return 'https://accounts.google.com/.well-known/openid-configuration'; + } + + if (AZURE_OIDC_STRATEGIES.has(normalizedStrategy) && clientId) { + const tenant = getFirstString(options, AZURE_TENANT_KEYS); + if (tenant) { + return `https://login.microsoftonline.com/${encodeURIComponent( + tenant, + )}/v2.0/.well-known/openid-configuration`; + } + } + + if (normalizedStrategy === 'okta' && clientId) { + const domain = getFirstString(options, OIDC_ISSUER_DOMAIN_KEYS); + if (domain) { + return normalizeDiscoveryEndpoint(ensureHttps(domain)); + } + } + + return null; +} + +function hasSamlConnectionData(options: UnknownRecord): boolean { + const metadataXml = getFirstString(options, SAML_XML_OPTION_KEYS); + const parsedMetadata = parseSamlMetadata(metadataXml); + return Boolean( + parsedMetadata.entityId || + parsedMetadata.ssoRedirectUrl || + parsedMetadata.x509Cert || + getFirstString(options, SAML_METADATA_URL_KEYS) || + getFirstString(options, SAML_IDP_ENTITY_ID_KEYS) || + getFirstString(options, SAML_IDP_URL_KEYS) || + getFirstString(options, SAML_CERT_KEYS), + ); +} + function buildOrganizationContext( connection: Auth0Connection, orgBindings: Auth0SsoConnectionOrgBinding[], @@ -721,6 +813,11 @@ function boolishString(value: unknown): string { return stringValue(value); } +function ensureHttps(value: string): string { + if (/^https?:\/\//i.test(value)) return value; + return `https://${value}`; +} + function buildAuth0CallbackUrl(domain: string, connectionName: string): string { return `https://${domain}/login/callback?connection=${encodeURIComponent(connectionName)}`; }