Last active
June 17, 2026 05:32
-
-
Save surajp/9896d6b1c1a4d4914b73886c9188c598 to your computer and use it in GitHub Desktop.
Set up SAML sso between 2 salesforce orgs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # | |
| # setup-sso.sh | |
| # Sets up a Salesforce spoke org with SAML SSO from a hub org (acting as IdP). | |
| # | |
| # Usage: ./setup-sso.sh <spoke_org> <hub_org> <saml_metadata_file_path> | |
| set -euo pipefail | |
| readonly API_VERSION="66.0" | |
| readonly BASE_DIR="saml-deploy" | |
| readonly DX_DIR="saml-sso" | |
| readonly DEFAULT_DIR="${BASE_DIR}/${DX_DIR}/main/default" | |
| # ----------------------------------------------------------------------------- | |
| # Helpers | |
| # ----------------------------------------------------------------------------- | |
| info() { printf ' %s\n' "$*"; } | |
| step() { printf '==> %s\n' "$*"; } | |
| die() { printf 'Error: %s\n' "$*" >&2; exit 1; } | |
| # Collapse non-alphanumeric chars into single underscores and trim leading/trailing. | |
| slugify() { | |
| printf '%s' "$1" | sed 's/[^a-zA-Z0-9][^a-zA-Z0-9]*/_/g; s/^_//; s/_$//' | |
| } | |
| # Extract subdomain from an https URL/entityID (e.g. https://foo.my.salesforce.com -> foo). | |
| subdomain_of() { | |
| printf '%s' "$1" | sed 's|^https://||; s/\..*$//' | |
| } | |
| # Require an external command to exist. | |
| require_cmd() { | |
| command -v "$1" &>/dev/null || die "'$1' is required but not found in PATH. Please install it." | |
| } | |
| # Read a single .result.<field> from `sf org display --json`. Dies if null/empty. | |
| org_field() { | |
| local json="$1" field="$2" label="$3" | |
| local val | |
| val=$(printf '%s' "${json}" | jq -r ".result.${field} // empty") | |
| [ -n "${val}" ] || die "Could not retrieve ${label}." | |
| printf '%s' "${val}" | |
| } | |
| # Deploy a metadata source path (relative to DEFAULT_DIR) to a target org. | |
| deploy() { | |
| local rel_path="$1" target_org="$2" | |
| ( cd "${BASE_DIR}" && \ | |
| sf project deploy start -d "${DX_DIR}/main/default/${rel_path}" -o "${target_org}" --ignore-conflicts ) | |
| } | |
| print_usage() { | |
| cat << EOF | |
| setup-sso.sh - Automates Salesforce SAML SSO configuration from a Hub Org to a Spoke Org. | |
| Usage: | |
| $0 <spoke_org_alias_or_username> <hub_org_alias_or_username> | |
| $0 -h | --help | |
| Arguments: | |
| <spoke_org_alias_or_username> Alias or username of the spoke org to configure SSO in. | |
| <hub_org_alias_or_username> Alias or username of the hub org acting as the identity provider (IdP). | |
| Description: | |
| Sets up SAML SSO between a hub org (IdP) and a spoke org (SP). Specifically, it: | |
| 1. Verifies (or enables) SAML in the spoke org. | |
| 2. Looks up spoke/hub org details and the hub user email. | |
| 3. Downloads the SAML IdP metadata from the hub org's well-known endpoint. | |
| 4. Parses the metadata (entityID, login/logout URLs, signing cert). | |
| 5. Finds or generates a self-signed certificate in the spoke org. | |
| 6. Deploys SamlSsoConfig to the spoke org. | |
| 7. Deploys an ExternalClientApplication (ECA) to the hub org. | |
| 8. Deploys SAML Configurable Policies to the hub org. | |
| 9. Derives the SAML login StartUrl and deploys ExtlClntAppConfigurablePolicies. | |
| 10. Syncs (or prompts to update) the FederationIdentifier of the active user in both orgs. | |
| Examples: | |
| $0 agent-scratch 9demo | |
| EOF | |
| } | |
| # ----------------------------------------------------------------------------- | |
| # Argument & prerequisite validation | |
| # ----------------------------------------------------------------------------- | |
| if [[ $# -gt 0 ]] && { [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; }; then | |
| print_usage | |
| exit 0 | |
| fi | |
| if [ $# -lt 2 ]; then | |
| echo "Error: Insufficient arguments." >&2 | |
| print_usage >&2 | |
| exit 1 | |
| fi | |
| SPOKE_ORG="$1" | |
| HUB_ORG="$2" | |
| require_cmd sf | |
| require_cmd jq | |
| require_cmd python3 | |
| require_cmd curl | |
| # Clean up the generated deploy project on exit. | |
| cleanup() { rm -rf "${BASE_DIR}"; } | |
| trap cleanup EXIT | |
| # ----------------------------------------------------------------------------- | |
| # Prepare target project configuration | |
| # ----------------------------------------------------------------------------- | |
| step "Preparing target project configuration in ${BASE_DIR}..." | |
| mkdir -p "${BASE_DIR}/${DX_DIR}" | |
| cat << EOF > "${BASE_DIR}/sfdx-project.json" | |
| { | |
| "packageDirectories": [ | |
| { | |
| "path": "${DX_DIR}", | |
| "default": true | |
| } | |
| ], | |
| "name": "SSO-Setup-Project", | |
| "namespace": "", | |
| "sfdcLoginUrl": "https://login.salesforce.com", | |
| "sourceApiVersion": "${API_VERSION}" | |
| } | |
| EOF | |
| # ----------------------------------------------------------------------------- | |
| # Step 1: Look up hub org details and download SAML IdP metadata | |
| # ----------------------------------------------------------------------------- | |
| step "Looking up details for hub org: ${HUB_ORG}..." | |
| HUB_INFO=$(sf org display -o "${HUB_ORG}" --json) | |
| HUB_ORG_ID=$(org_field "${HUB_INFO}" "id" "Org ID for hub org: ${HUB_ORG}") | |
| HUB_ORG_ID="${HUB_ORG_ID:0:15}" | |
| HUB_MYDOMAIN=$(org_field "${HUB_INFO}" "instanceUrl" "instanceUrl for hub org: ${HUB_ORG}") | |
| HUB_USERNAME=$(org_field "${HUB_INFO}" "username" "username for hub org: ${HUB_ORG}") | |
| info "Hub Org ID: ${HUB_ORG_ID}" | |
| info "Hub MyDomain URL: ${HUB_MYDOMAIN}" | |
| SAML_METADATA_URL="${HUB_MYDOMAIN%/}/.well-known/samlidp.xml" | |
| SAML_METADATA_FILE="${BASE_DIR}/samlidp.xml" | |
| step "Downloading SAML IdP metadata from hub org: ${SAML_METADATA_URL}..." | |
| HTTP_STATUS=$(curl -s -o "${SAML_METADATA_FILE}" -w "%{http_code}" "${SAML_METADATA_URL}") | |
| if [ "${HTTP_STATUS}" != "200" ]; then | |
| die "Failed to download SAML IdP metadata from the hub org (HTTP ${HTTP_STATUS}). | |
| The hub org's Identity Provider is likely not enabled. To enable it: | |
| 1. Log in to the hub org: ${HUB_MYDOMAIN} | |
| 2. Go to Setup -> Identity -> Identity Provider | |
| 3. Click 'Enable Identity Provider' and save | |
| 4. Re-run this script." | |
| fi | |
| [ -s "${SAML_METADATA_FILE}" ] || die "Downloaded SAML IdP metadata file is empty." | |
| info "Metadata downloaded successfully." | |
| step "Parsing SAML IdP metadata file..." | |
| PARSED_DATA=$(python3 -c " | |
| import sys, xml.etree.ElementTree as ET, re | |
| file_path = sys.argv[1] | |
| try: | |
| tree = ET.parse(file_path) | |
| root = tree.getroot() | |
| namespaces = {'md': 'urn:oasis:names:tc:SAML:2.0:metadata', 'ds': 'http://www.w3.org/2000/09/xmldsig#'} | |
| entity_id = root.attrib.get('entityID') | |
| cert_node = root.find('.//ds:X509Certificate', namespaces) | |
| if cert_node is None: | |
| cert_node = root.find('.//X509Certificate') | |
| cert_val = cert_node.text.strip() if cert_node is not None else '' | |
| sso_node = root.find('.//md:SingleSignOnService[@Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"]', namespaces) | |
| if sso_node is None: | |
| sso_node = root.find('.//md:SingleSignOnService', namespaces) | |
| sso_url = sso_node.attrib.get('Location') if sso_node is not None else '' | |
| slo_node = root.find('.//md:SingleLogoutService[@Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\"]', namespaces) | |
| if slo_node is None: | |
| slo_node = root.find('.//md:SingleLogoutService', namespaces) | |
| slo_url = slo_node.attrib.get('Location') if slo_node is not None else '' | |
| # Fallback regex parsing for non-standard metadata documents. | |
| if not entity_id or not cert_val or not sso_url or not slo_url: | |
| with open(file_path, 'r') as f: | |
| content = f.read() | |
| if not entity_id: | |
| m = re.search(r'entityID=\"([^\"]+)\"', content) | |
| entity_id = m.group(1) if m else '' | |
| if not cert_val: | |
| m = re.search(r'<[^:]*:?X509Certificate>([\s\S]+?)</[^:]*:?X509Certificate>', content) | |
| cert_val = re.sub(r'\s+', '', m.group(1)) if m else '' | |
| if not sso_url: | |
| m = re.search(r'Location=\"([^\"]+)\"[^>]*HTTP-Redirect', content) | |
| if not m: | |
| m = re.search(r'HTTP-Redirect[^>]*Location=\"([^\"]+)\"', content) | |
| if not m: | |
| m = re.search(r'Location=\"([^\"]+)\"', content) | |
| sso_url = m.group(1) if m else '' | |
| if not slo_url: | |
| slo_url = entity_id.rstrip('/') + '/services/auth/idp/saml2/logout' if entity_id else '' | |
| cert_val = re.sub(r'\s+', '', cert_val) | |
| print(f'{entity_id}#{sso_url}#{slo_url}#{cert_val}') | |
| except Exception as e: | |
| print(f'ERROR: {str(e)}', file=sys.stderr) | |
| sys.exit(1) | |
| " "${SAML_METADATA_FILE}") | |
| HUB_ENTITY_ID=$(echo "${PARSED_DATA}" | cut -d'#' -f1) | |
| HUB_LOGIN_URL=$(echo "${PARSED_DATA}" | cut -d'#' -f2) | |
| HUB_LOGOUT_URL=$(echo "${PARSED_DATA}" | cut -d'#' -f3) | |
| HUB_CERT_VALUE=$(echo "${PARSED_DATA}" | cut -d'#' -f4) | |
| if [ -z "${HUB_ENTITY_ID}" ] || [ -z "${HUB_CERT_VALUE}" ]; then | |
| die "Failed to parse hub entityID or certificate from SAML metadata file." | |
| fi | |
| HUB_IDENTIFIER=$(slugify "$(subdomain_of "${HUB_ENTITY_ID}")") | |
| info "Hub Entity ID: ${HUB_ENTITY_ID}" | |
| info "Hub Subdomain Identifier: ${HUB_IDENTIFIER}" | |
| step "Querying active hub user email..." | |
| HUB_EMAIL=$(sf data query -q "SELECT Email FROM User WHERE Username = '${HUB_USERNAME}'" -o "${HUB_ORG}" --json \ | |
| | jq -r '.result.records[0].Email // empty') | |
| [ -n "${HUB_EMAIL}" ] || die "Could not retrieve email for hub org user: ${HUB_USERNAME}" | |
| info "Hub User Email: ${HUB_EMAIL}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 2: Look up spoke org details and enable SAML if needed | |
| # ----------------------------------------------------------------------------- | |
| step "Looking up details for spoke org: ${SPOKE_ORG}..." | |
| SPOKE_INFO=$(sf org display -o "${SPOKE_ORG}" --json) | |
| SPOKE_MYDOMAIN=$(org_field "${SPOKE_INFO}" "instanceUrl" "instanceUrl for spoke org: ${SPOKE_ORG}") | |
| SPOKE_USERNAME=$(org_field "${SPOKE_INFO}" "username" "username for spoke org: ${SPOKE_ORG}") | |
| info "Spoke MyDomain URL: ${SPOKE_MYDOMAIN}" | |
| step "Checking if SAML is enabled in spoke org: ${SPOKE_ORG}..." | |
| SAML_ENABLED=$(sf data query --use-tooling-api \ | |
| -q "SELECT IsSamlLoginEnabled FROM SingleSignOnSettings" -o "${SPOKE_ORG}" --json \ | |
| | jq -r '.result.records[0].IsSamlLoginEnabled') | |
| if [ "${SAML_ENABLED}" != "true" ]; then | |
| info "SAML is not enabled. Enabling it now..." | |
| sfdx data:update:record -s SingleSignOnSettings -o "${SPOKE_ORG}" -t -i 000000000000000AAA -v "IsSamlLoginEnabled=true" | |
| info "SAML enabled." | |
| else | |
| info "SAML is already enabled." | |
| fi | |
| # ----------------------------------------------------------------------------- | |
| # Step 3: Get or create a self-signed certificate in the spoke org | |
| # ----------------------------------------------------------------------------- | |
| step "Querying for self-signed certificate ID in spoke org: ${SPOKE_ORG}..." | |
| CERT_DIR="${DEFAULT_DIR}/certs" | |
| CERT_QUERY_RESULT=$(sf data query --use-tooling-api \ | |
| -q "SELECT Id, DeveloperName FROM Certificate" -o "${SPOKE_ORG}" --json) | |
| CERT_COUNT=$(echo "${CERT_QUERY_RESULT}" | jq -r '.result.records | length') | |
| if [ "${CERT_COUNT}" -eq 0 ]; then | |
| step "No certificates found in spoke org. Generating a self-signed certificate..." | |
| require_cmd openssl | |
| CERT_NAME="SelfSignedCert_SSO" | |
| mkdir -p "${CERT_DIR}" | |
| info "Generating private key and certificate files..." | |
| openssl req -x509 -newkey rsa:2048 -keyout "${CERT_DIR}/${CERT_NAME}.key" \ | |
| -out "${CERT_DIR}/${CERT_NAME}.crt" -sha256 -days 365 -nodes -subj "/CN=${CERT_NAME}" | |
| info "Creating certificate metadata file..." | |
| cat << EOF > "${CERT_DIR}/${CERT_NAME}.crt-meta.xml" | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <Certificate xmlns="http://soap.sforce.com/2006/04/metadata"> | |
| <caSigned>false</caSigned> | |
| <encryptedWithPlatformEncryption>false</encryptedWithPlatformEncryption> | |
| <keySize>2048</keySize> | |
| <masterLabel>${CERT_NAME}</masterLabel> | |
| <privateKeyExportable>true</privateKeyExportable> | |
| </Certificate> | |
| EOF | |
| info "Deploying certificate to spoke org: ${SPOKE_ORG}..." | |
| deploy "certs/${CERT_NAME}.crt-meta.xml" "${SPOKE_ORG}" | |
| info "Re-querying for the created certificate ID..." | |
| SPOKE_CERT_ID=$(sf data query --use-tooling-api \ | |
| -q "SELECT Id FROM Certificate WHERE DeveloperName = '${CERT_NAME}'" -o "${SPOKE_ORG}" --json \ | |
| | jq -r '.result.records[0].Id // empty') | |
| else | |
| # Prefer a certificate named SelfSignedCert_*, otherwise fall back to the first one. | |
| SPOKE_CERT_ID=$(echo "${CERT_QUERY_RESULT}" \ | |
| | jq -r '.result.records[] | select(.DeveloperName | startswith("SelfSignedCert_")) | .Id' | head -n 1) | |
| if [ -z "${SPOKE_CERT_ID}" ] || [ "${SPOKE_CERT_ID}" == "null" ]; then | |
| SPOKE_CERT_ID=$(echo "${CERT_QUERY_RESULT}" | jq -r '.result.records[0].Id') | |
| fi | |
| fi | |
| if [ -z "${SPOKE_CERT_ID}" ] || [ "${SPOKE_CERT_ID}" == "null" ]; then | |
| die "Could not determine certificate ID from spoke org." | |
| fi | |
| # Truncate to 15 chars for the requestSigningCertId field requirement. | |
| SPOKE_CERT_ID="${SPOKE_CERT_ID:0:15}" | |
| info "Spoke Certificate ID: ${SPOKE_CERT_ID}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 4: Generate and deploy SamlSsoConfig to spoke org | |
| # ----------------------------------------------------------------------------- | |
| SAML_CONFIG_DIR="${DEFAULT_DIR}/samlssoconfigs" | |
| step "Creating and deploying SAML SSO config to spoke org: ${SPOKE_ORG}..." | |
| mkdir -p "${SAML_CONFIG_DIR}" | |
| cat << EOF > "${SAML_CONFIG_DIR}/${HUB_IDENTIFIER}.samlssoconfig-meta.xml" | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <SamlSsoConfig xmlns="http://soap.sforce.com/2006/04/metadata"> | |
| <identityLocation>SubjectNameId</identityLocation> | |
| <identityMapping>FederationId</identityMapping> | |
| <issuer>${HUB_ENTITY_ID}</issuer> | |
| <loginUrl>${HUB_LOGIN_URL}</loginUrl> | |
| <name>${HUB_IDENTIFIER}</name> | |
| <redirectBinding>true</redirectBinding> | |
| <requestSignatureMethod>1</requestSignatureMethod> | |
| <requestSigningCertId>${SPOKE_CERT_ID}</requestSigningCertId> | |
| <samlEntityId>${SPOKE_MYDOMAIN}</samlEntityId> | |
| <samlVersion>SAML2_0</samlVersion> | |
| <singleLogoutBinding>RedirectBinding</singleLogoutBinding> | |
| <singleLogoutUrl>${HUB_LOGOUT_URL}</singleLogoutUrl> | |
| <useConfigRequestMethod>false</useConfigRequestMethod> | |
| <useSameDigestAlgoForSigning>true</useSameDigestAlgoForSigning> | |
| <userProvisioning>false</userProvisioning> | |
| <validationCert>${HUB_CERT_VALUE}</validationCert> | |
| </SamlSsoConfig> | |
| EOF | |
| deploy "samlssoconfigs/${HUB_IDENTIFIER}.samlssoconfig-meta.xml" "${SPOKE_ORG}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 5: Determine spoke org identifier (used as ECA developer name) | |
| # ----------------------------------------------------------------------------- | |
| ALIAS=$(echo "${SPOKE_INFO}" | jq -r '.result.alias // empty') | |
| if [ -n "${ALIAS}" ]; then | |
| SPOKE_IDENTIFIER=$(slugify "${ALIAS}") | |
| else | |
| SPOKE_IDENTIFIER=$(slugify "$(subdomain_of "${SPOKE_MYDOMAIN}")") | |
| fi | |
| ECA_DEVELOPER_NAME="${SPOKE_IDENTIFIER}" | |
| ECA_LABEL="${SPOKE_IDENTIFIER//_/ }" | |
| info "Derived ECA Developer Name: ${ECA_DEVELOPER_NAME}" | |
| info "Derived ECA Label: ${ECA_LABEL}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 6: Generate and deploy External Client App (ECA) to hub org | |
| # ----------------------------------------------------------------------------- | |
| ECA_DIR="${DEFAULT_DIR}/externalClientApps" | |
| step "Creating and deploying External Client App to hub org: ${HUB_ORG}..." | |
| mkdir -p "${ECA_DIR}" | |
| cat << EOF > "${ECA_DIR}/${ECA_DEVELOPER_NAME}.eca-meta.xml" | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <ExternalClientApplication xmlns="http://soap.sforce.com/2006/04/metadata"> | |
| <contactEmail>${HUB_EMAIL}</contactEmail> | |
| <distributionState>Local</distributionState> | |
| <iconUrl>https://login.salesforce.com/logos/Salesforce/Salesforce/icon.png</iconUrl> | |
| <isProtected>false</isProtected> | |
| <label>${ECA_LABEL}</label> | |
| <logoUrl>https://login.salesforce.com/logos/Salesforce/Salesforce/logo.png</logoUrl> | |
| <orgScopedExternalApp>${HUB_ORG_ID}:${ECA_DEVELOPER_NAME}</orgScopedExternalApp> | |
| </ExternalClientApplication> | |
| EOF | |
| deploy "externalClientApps/${ECA_DEVELOPER_NAME}.eca-meta.xml" "${HUB_ORG}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 7: Generate and deploy SAML Configurable Policies to hub org | |
| # ----------------------------------------------------------------------------- | |
| POLICY_DIR="${DEFAULT_DIR}/extlClntAppSamlConfigurablePolicies" | |
| step "Creating and deploying SAML Configurable Policy to hub org: ${HUB_ORG}..." | |
| mkdir -p "${POLICY_DIR}" | |
| cat << EOF > "${POLICY_DIR}/${ECA_DEVELOPER_NAME}_samlPlcy.ecaSamlPlcy-meta.xml" | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <ExtlClntAppSamlConfigurablePolicies xmlns="http://soap.sforce.com/2006/04/metadata"> | |
| <acsUrl>${SPOKE_MYDOMAIN}</acsUrl> | |
| <commaSeparatedProfile>System Administrator</commaSeparatedProfile> | |
| <encryptionType>AES_128</encryptionType> | |
| <entityUrl>${SPOKE_MYDOMAIN}</entityUrl> | |
| <externalClientApplication>${ECA_DEVELOPER_NAME}</externalClientApplication> | |
| <issuer>${HUB_MYDOMAIN}</issuer> | |
| <label>${ECA_DEVELOPER_NAME}_samlPlcy</label> | |
| <nameIdFormat>Unspecified</nameIdFormat> | |
| <signingAlgorithmType>SHA1</signingAlgorithmType> | |
| <subjectType>FederationId</subjectType> | |
| </ExtlClntAppSamlConfigurablePolicies> | |
| EOF | |
| deploy "extlClntAppSamlConfigurablePolicies/${ECA_DEVELOPER_NAME}_samlPlcy.ecaSamlPlcy-meta.xml" "${HUB_ORG}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 8: Derive StartUrl and deploy Configurable Policies to hub org | |
| # ----------------------------------------------------------------------------- | |
| step "Retrieving SAML login info (StartUrl) for External Client App from hub org..." | |
| SAML_POLICY_CONFIG_ID="" | |
| for _ in {1..10}; do | |
| SAML_POLICY_CONFIG_ID=$(sf data query --use-tooling-api \ | |
| -q "SELECT Id FROM ExtlClntAppSamlPlcyCnfg WHERE DeveloperName = '${ECA_DEVELOPER_NAME}_samlPlcy'" \ | |
| -o "${HUB_ORG}" --json | jq -r '.result.records[0].Id // empty') | |
| [ -n "${SAML_POLICY_CONFIG_ID}" ] && break | |
| info "SAML Policy Config record not found yet. Waiting 2 seconds..." | |
| sleep 2 | |
| done | |
| [ -n "${SAML_POLICY_CONFIG_ID}" ] || \ | |
| die "Could not retrieve ExtlClntAppSamlPlcyCnfg ID for: ${ECA_DEVELOPER_NAME}_samlPlcy" | |
| DERIVED_START_URL="${HUB_MYDOMAIN%/}/idp/login?app=${SAML_POLICY_CONFIG_ID:0:15}" | |
| info "Derived SAML login StartUrl: ${DERIVED_START_URL}" | |
| CONFIG_POLICY_DIR="${DEFAULT_DIR}/extlClntAppPolicies" | |
| step "Creating and deploying External Client App Configurable Policies to hub org: ${HUB_ORG}..." | |
| mkdir -p "${CONFIG_POLICY_DIR}" | |
| cat << EOF > "${CONFIG_POLICY_DIR}/${ECA_DEVELOPER_NAME}_plcy.ecaPlcy-meta.xml" | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <ExtlClntAppConfigurablePolicies xmlns="http://soap.sforce.com/2006/04/metadata"> | |
| <externalClientApplication>${ECA_DEVELOPER_NAME}</externalClientApplication> | |
| <isEnabled>true</isEnabled> | |
| <isOauthPluginEnabled>false</isOauthPluginEnabled> | |
| <isSamlPluginEnabled>true</isSamlPluginEnabled> | |
| <label>${ECA_DEVELOPER_NAME}_plcy</label> | |
| <startPage>Custom</startPage> | |
| <startUrl>${DERIVED_START_URL}</startUrl> | |
| </ExtlClntAppConfigurablePolicies> | |
| EOF | |
| deploy "extlClntAppPolicies/${ECA_DEVELOPER_NAME}_plcy.ecaPlcy-meta.xml" "${HUB_ORG}" | |
| # ----------------------------------------------------------------------------- | |
| # Step 9: Check and sync User Federation Identifiers | |
| # ----------------------------------------------------------------------------- | |
| step "Checking User Federation Identifiers..." | |
| SPOKE_USER_INFO=$(sf data query \ | |
| -q "SELECT Id, FederationIdentifier FROM User WHERE Username = '${SPOKE_USERNAME}'" -o "${SPOKE_ORG}" --json) | |
| SPOKE_FED_ID=$(echo "${SPOKE_USER_INFO}" | jq -r '.result.records[0].FederationIdentifier // ""') | |
| SPOKE_USER_ID=$(echo "${SPOKE_USER_INFO}" | jq -r '.result.records[0].Id') | |
| HUB_FED_ID=$(sf data query \ | |
| -q "SELECT FederationIdentifier FROM User WHERE Username = '${HUB_USERNAME}'" -o "${HUB_ORG}" --json \ | |
| | jq -r '.result.records[0].FederationIdentifier // ""') | |
| info "Spoke User Fed ID: '${SPOKE_FED_ID}'" | |
| info "Hub User Fed ID: '${HUB_FED_ID}'" | |
| if [ -z "${HUB_FED_ID}" ]; then | |
| echo "Warning: Hub Org User Federation Identifier is blank." | |
| elif [ -z "${SPOKE_FED_ID}" ]; then | |
| step "Spoke Org Fed ID is blank. Setting it to Hub Org Fed ID: '${HUB_FED_ID}'..." | |
| sf data update record -s User -i "${SPOKE_USER_ID}" -v "FederationIdentifier=${HUB_FED_ID}" -o "${SPOKE_ORG}" | |
| info "Spoke Org User FederationIdentifier updated successfully." | |
| elif [ "${HUB_FED_ID}" == "${SPOKE_FED_ID}" ]; then | |
| echo "Success: User Federation Identifiers match between Hub and Spoke orgs." | |
| else | |
| choice="n" | |
| if [ -t 0 ]; then | |
| read -p "Warning: Spoke Org Fed ID (${SPOKE_FED_ID}) does not match Hub Org Fed ID (${HUB_FED_ID}). Update Spoke to match Hub? (y/N): " choice | |
| else | |
| echo "Warning: Non-interactive terminal. Spoke Org Fed ID (${SPOKE_FED_ID}) != Hub Org Fed ID (${HUB_FED_ID}). Update skipped." | |
| fi | |
| if [[ "${choice}" =~ ^[Yy]$ ]]; then | |
| sf data update record -s User -i "${SPOKE_USER_ID}" -v "FederationIdentifier=${HUB_FED_ID}" -o "${SPOKE_ORG}" | |
| info "Spoke Org User FederationIdentifier updated successfully." | |
| fi | |
| fi | |
| step "SSO Setup Complete!" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
setup-sso.sh
Automates SAML Single Sign-On configuration between two Salesforce orgs:
The script generates the required metadata locally, deploys it to both orgs via the
Salesforce CLI, and synchronizes the user's Federation Identifier so SSO works end-to-end.
The script is idempotent: re-running it with the same arguments is safe and produces the
same end state. Metadata deployments overwrite existing records by developer name, the
certificate step reuses any existing certificate rather than creating a duplicate, and the
Federation Identifier sync no-ops when the values already match.
Prerequisites
sf(Salesforce CLI)jqpython3curlopensslBoth orgs must already be authenticated with the Salesforce CLI (
sf org login web),and the hub org must have Identity Provider enabled (Setup → Identity → Identity Provider)
with a certificate already created.
Usage
Arguments
<spoke_org><hub_org>Example
What the script does
<hub_mydomain>/.well-known/samlidp.xml)and parses it for the entity ID, login/logout URLs, and signing certificate
(with a regex fallback for non-standard documents). Exits on failure.
opensslif none exists).SamlSsoConfigto the spoke org.ExternalClientApplication(ECA) to the hub org.ExtlClntAppSamlConfigurablePolicies) to the hub org.StartUrlfrom the hub org and deploysExtlClntAppConfigurablePolicies.Generated files & cleanup
The script creates a temporary Salesforce DX project under
saml-deploy/to stage the metadatait deploys. This directory is automatically removed on exit via a cleanup trap, so no
artifacts are left behind.
Notes
API_VERSION(currently66.0) near the top of the script.edit the
commaSeparatedProfilevalue in the script to target other profiles.set -euo pipefail, so any unexpected CLI failure aborts the run.