Skip to content

Instantly share code, notes, and snippets.

@surajp
Last active June 17, 2026 05:32
Show Gist options
  • Select an option

  • Save surajp/9896d6b1c1a4d4914b73886c9188c598 to your computer and use it in GitHub Desktop.

Select an option

Save surajp/9896d6b1c1a4d4914b73886c9188c598 to your computer and use it in GitHub Desktop.
Set up SAML sso between 2 salesforce orgs
#!/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!"
@surajp

surajp commented Jun 13, 2026

Copy link
Copy Markdown
Author

setup-sso.sh

Automates SAML Single Sign-On configuration between two Salesforce orgs:

  • Hub org — acts as the SAML Identity Provider (IdP).
  • Spoke org — acts as the Service Provider (SP) that trusts the hub for login.

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

Tool Purpose
sf (Salesforce CLI) Querying orgs and deploying metadata
jq Parsing CLI JSON output
python3 Parsing the SAML IdP metadata XML
curl Downloading the IdP metadata XML from the hub org
openssl Only required if the spoke org has no existing certificate

Both 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

./setup-sso.sh <spoke_org> <hub_org>
./setup-sso.sh -h | --help

Arguments

Argument Description
<spoke_org> Alias or username of the spoke org to configure SSO in.
<hub_org> Alias or username of the hub org acting as the IdP.

Example

./setup-sso.sh agent-scratch 9demo

What the script does

  1. Validates arguments and prerequisites.
  2. Downloads the IdP metadata XML from the hub org (<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.
  3. Looks up spoke org details (My Domain URL, username) and the hub user email.
  4. Enables SAML in the spoke org if it is not already on (Setup → Single Sign-On Settings).
  5. Finds or generates a self-signed certificate in the spoke org (creates and deploys one with
    openssl if none exists).
  6. Deploys SamlSsoConfig to the spoke org.
  7. Deploys an ExternalClientApplication (ECA) to the hub org.
  8. Deploys SAML Configurable Policies (ExtlClntAppSamlConfigurablePolicies) to the hub org.
  9. Derives the SAML login StartUrl from the hub org and deploys
    ExtlClntAppConfigurablePolicies.
  10. Syncs the Federation Identifier of the active user across both orgs:
    • If the spoke value is blank, it is set to the hub value automatically.
    • If they differ, you are prompted to update the spoke value (skipped in non-interactive shells).

Generated files & cleanup

The script creates a temporary Salesforce DX project under saml-deploy/ to stage the metadata
it deploys. This directory is automatically removed on exit via a cleanup trap, so no
artifacts are left behind.

Notes

  • The deployment API version is pinned via API_VERSION (currently 66.0) near the top of the script.
  • SAML Configurable Policies grant access to the System Administrator profile by default;
    edit the commaSeparatedProfile value in the script to target other profiles.
  • The script uses set -euo pipefail, so any unexpected CLI failure aborts the run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment