Last active
April 24, 2026 21:08
-
-
Save samcofer/0fa70e259a0cee9b412d4155234fef62 to your computer and use it in GitHub Desktop.
Posit OIDC & SCIM configuration for Microsoft Entra ID (Bash)
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 | |
| set -euo pipefail | |
| need() { command -v "$1" >/dev/null || { echo "Missing required command: $1"; exit 1; }; } | |
| need az | |
| need jq | |
| normalize_yesno() { | |
| case "${1,,}" in | |
| y|yes) echo "Yes" ;; | |
| n|no) echo "No" ;; | |
| *) return 1 ;; | |
| esac | |
| } | |
| validate_https_url() { | |
| local url="$1" suffix="${2:-}" | |
| if [[ ! "$url" =~ ^https:// ]]; then | |
| echo "URL must start with https://" | |
| return 1 | |
| fi | |
| if [[ -n "$suffix" && "$url" != *"$suffix" ]]; then | |
| echo "URL must end with $suffix" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| prompt() { | |
| local var="$1" label="$2" default="${3:-}" secret="${4:-false}" | |
| [[ -n "${!var:-}" ]] && return 0 | |
| local value | |
| while true; do | |
| if [[ "$secret" == "true" ]]; then | |
| read -rsp "$label${default:+ [$default]}: " value </dev/tty | |
| else | |
| read -rp "$label${default:+ [$default]}: " value </dev/tty | |
| fi | |
| echo | |
| value="${value:-$default}" | |
| if [[ -n "$value" ]]; then break; fi | |
| echo "A value is required." | |
| done | |
| export "$var=$value" | |
| } | |
| prompt_url() { | |
| local var="$1" label="$2" default="${3:-}" suffix="${4:-}" | |
| if [[ -n "${!var:-}" ]]; then | |
| validate_https_url "${!var}" "$suffix" || exit 1 | |
| return 0 | |
| fi | |
| while true; do | |
| read -rp "$label${default:+ [$default]}: " value </dev/tty | |
| echo | |
| value="${value:-$default}" | |
| if [[ -z "$value" ]]; then | |
| echo "A value is required." | |
| continue | |
| fi | |
| if validate_https_url "$value" "$suffix"; then break; fi | |
| done | |
| export "$var=$value" | |
| } | |
| yesno() { | |
| local var="$1" label="$2" default="${3:-No}" value normalized | |
| if [[ -n "${!var:-}" ]]; then | |
| normalized="$(normalize_yesno "${!var}")" || { | |
| echo "Invalid value for $var: ${!var}. Use Yes or No." | |
| exit 1 | |
| } | |
| export "$var=$normalized" | |
| return 0 | |
| fi | |
| while true; do | |
| read -rp "$label [Yes/No, default $default]: " value </dev/tty | |
| echo | |
| value="${value:-$default}" | |
| normalized="$(normalize_yesno "$value")" && break | |
| echo "Please enter Yes or No." | |
| done | |
| export "$var=$normalized" | |
| } | |
| POSIT_LOGO_PNG_B64="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" | |
| set_app_logo() { | |
| local app_object_id="$1" | |
| echo "Setting application logo..." | |
| local logo_file | |
| logo_file="$(mktemp)" | |
| echo -n "$POSIT_LOGO_PNG_B64" | base64 -d > "$logo_file" | |
| local token | |
| token="$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv)" | |
| if curl -sf -X PUT \ | |
| "https://graph.microsoft.com/v1.0/applications/$app_object_id/logo" \ | |
| -H "Authorization: Bearer $token" \ | |
| -H "Content-Type: image/png" \ | |
| --data-binary "@$logo_file" >/dev/null 2>&1; then | |
| echo "Application logo set." | |
| else | |
| echo "WARNING: Failed to set application logo (non-fatal)." | |
| fi | |
| rm -f "$logo_file" | |
| } | |
| truncate_name() { | |
| local base="$1" suffix="$2" max="${3:-120}" | |
| local allowed=$((max - ${#suffix})) | |
| if (( allowed < 1 )); then | |
| printf "%s" "${suffix:0:$max}" | |
| else | |
| printf "%s%s" "${base:0:$allowed}" "$suffix" | |
| fi | |
| } | |
| select_product() { | |
| if [[ -n "${PRODUCT:-}" ]]; then | |
| case "${PRODUCT,,}" in | |
| workbench|1) PRODUCT="workbench" ;; | |
| connect|2) PRODUCT="connect" ;; | |
| packagemanager|ppm|3) PRODUCT="packagemanager" ;; | |
| *) echo "Invalid PRODUCT value: $PRODUCT"; exit 1 ;; | |
| esac | |
| return 0 | |
| fi | |
| echo "" | |
| echo "Select Posit product to configure:" | |
| echo " 1) Posit Workbench" | |
| echo " 2) Posit Connect" | |
| echo " 3) Posit Package Manager" | |
| echo "" | |
| local choice | |
| while true; do | |
| read -rp "Product [1/2/3]: " choice </dev/tty | |
| echo | |
| case "$choice" in | |
| 1|workbench) PRODUCT="workbench"; break ;; | |
| 2|connect) PRODUCT="connect"; break ;; | |
| 3|packagemanager|ppm) PRODUCT="packagemanager"; break ;; | |
| *) echo "Please enter 1, 2, or 3." ;; | |
| esac | |
| done | |
| export PRODUCT | |
| } | |
| print_collected_info() { | |
| cat <<EOF | |
| Collected information so far | |
| ============================ | |
| Product: ${PRODUCT:-} | |
| Tenant ID: ${TENANT_ID:-} | |
| Skip OIDC: ${SKIP_OIDC:-} | |
| OIDC: | |
| App name: ${APP_NAME:-} | |
| Base URL: ${BASE_URL:-} | |
| Redirect URI: ${REDIRECT_URI:-} | |
| Client secret name: ${CLIENT_SECRET_NAME:-} | |
| Sign-in audience: ${SIGNIN_AUDIENCE:-} | |
| Include groups: ${INCLUDE_GROUP_CLAIMS:-} | |
| Group claim mode: ${GROUP_CLAIMS:-} | |
| Client ID: ${CLIENT_ID:-} | |
| App object ID: ${APP_OBJECT_ID:-} | |
| Enterprise SP ID: ${SP_OBJECT_ID:-} | |
| SCIM: | |
| Create SCIM: ${CREATE_SCIM:-} | |
| App name: ${SCIM_APP_NAME:-} | |
| SCIM URL: ${SCIM_URL:-} | |
| SCIM app/client ID: ${SCIM_APP_ID:-} | |
| SCIM SP ID: ${SCIM_SP_ID:-} | |
| SCIM job ID: ${SCIM_JOB_ID:-} | |
| Start SCIM: ${START_SCIM:-} | |
| Secrets: | |
| OIDC client secret: ${CLIENT_SECRET:-} | |
| SCIM token: ${SCIM_TOKEN:+<collected but hidden>} | |
| EOF | |
| } | |
| on_error() { | |
| local exit_code=$? | |
| echo | |
| echo "Script failed with exit code $exit_code." | |
| print_collected_info | |
| exit "$exit_code" | |
| } | |
| trap on_error ERR | |
| echo "Checking Azure login..." | |
| ACCOUNT_JSON="$(az account show -o json)" | |
| TENANT_ID="$(jq -r '.tenantId' <<<"$ACCOUNT_JSON")" | |
| SIGNED_IN_USER="$(az ad signed-in-user show --query id -o tsv)" | |
| GRAPH_APP_ID="00000003-0000-0000-c000-000000000000" | |
| GRAPH_SP_ID="$(az ad sp show --id "$GRAPH_APP_ID" --query id -o tsv)" | |
| SCIM_TEMPLATE_ID="8adf8e6e-67b2-4cf2-a259-e3dc5476c621" | |
| select_product | |
| case "$PRODUCT" in | |
| workbench) | |
| DEFAULT_APP_NAME="posit-workbench-oidc" | |
| PRODUCT_LABEL="Posit Workbench" | |
| URL_EXAMPLE="https://workbench.example.com" | |
| ;; | |
| connect) | |
| DEFAULT_APP_NAME="posit-connect-oidc" | |
| PRODUCT_LABEL="Posit Connect" | |
| URL_EXAMPLE="https://connect.example.com" | |
| ;; | |
| packagemanager) | |
| DEFAULT_APP_NAME="posit-package-manager-oidc" | |
| PRODUCT_LABEL="Posit Package Manager" | |
| URL_EXAMPLE="https://packagemanager.example.com" | |
| ;; | |
| esac | |
| echo "" | |
| echo "Configuring Entra ID for $PRODUCT_LABEL" | |
| echo "========================================" | |
| if [[ "$PRODUCT" == "workbench" ]]; then | |
| if [[ -n "${WB_MODE:-}" ]]; then | |
| case "${WB_MODE,,}" in | |
| 1|oidc-scim|oidc+scim) SKIP_OIDC="No"; CREATE_SCIM="Yes" ;; | |
| 2|oidc) SKIP_OIDC="No"; CREATE_SCIM="No" ;; | |
| 3|scim) SKIP_OIDC="Yes"; CREATE_SCIM="Yes" ;; | |
| *) echo "Invalid WB_MODE value: $WB_MODE. Use oidc+scim, oidc, or scim."; exit 1 ;; | |
| esac | |
| else | |
| echo "" | |
| echo "Select Workbench configuration mode:" | |
| echo " 1) OIDC + SCIM provisioning" | |
| echo " 2) OIDC only" | |
| echo " 3) SCIM provisioning only" | |
| echo "" | |
| while true; do | |
| read -rp "Mode [1/2/3]: " wb_choice </dev/tty | |
| echo | |
| case "$wb_choice" in | |
| 1) SKIP_OIDC="No"; CREATE_SCIM="Yes"; break ;; | |
| 2) SKIP_OIDC="No"; CREATE_SCIM="No"; break ;; | |
| 3) SKIP_OIDC="Yes"; CREATE_SCIM="Yes"; break ;; | |
| *) echo "Please enter 1, 2, or 3." ;; | |
| esac | |
| done | |
| fi | |
| else | |
| SKIP_OIDC="No" | |
| CREATE_SCIM="No" | |
| fi | |
| if [[ "$SKIP_OIDC" != "Yes" ]]; then | |
| prompt APP_NAME "App registration name" "$DEFAULT_APP_NAME" | |
| prompt_url BASE_URL "$PRODUCT_LABEL base URL" "$URL_EXAMPLE" | |
| case "$PRODUCT" in | |
| workbench) | |
| DEFAULT_REDIRECT="${BASE_URL%/}/openid/callback" | |
| REDIRECT_SUFFIX="/openid/callback" | |
| ;; | |
| connect|packagemanager) | |
| DEFAULT_REDIRECT="${BASE_URL%/}/__login__/callback" | |
| REDIRECT_SUFFIX="/__login__/callback" | |
| ;; | |
| esac | |
| prompt_url REDIRECT_URI "OIDC redirect URI" "$DEFAULT_REDIRECT" "$REDIRECT_SUFFIX" | |
| prompt CLIENT_SECRET_NAME "Client secret display name" "${APP_NAME}-secret" | |
| prompt SIGNIN_AUDIENCE "Sign-in audience: AzureADMyOrg, AzureADMultipleOrgs" "AzureADMyOrg" | |
| yesno INCLUDE_GROUP_CLAIMS "Include group claims in ID/access tokens?" "Yes" | |
| prompt GROUP_CLAIMS "Group claim mode: SecurityGroup, All, DirectoryRole, ApplicationGroup, None" "SecurityGroup" | |
| if [[ "$INCLUDE_GROUP_CLAIMS" == "Yes" ]]; then | |
| GROUP_MEMBERSHIP_CLAIMS="$GROUP_CLAIMS" | |
| else | |
| GROUP_MEMBERSHIP_CLAIMS="None" | |
| fi | |
| # --- Collect SCIM prompts early for unified mode --- | |
| if [[ "$CREATE_SCIM" == "Yes" ]]; then | |
| DEFAULT_SCIM_URL="${BASE_URL%/}/scim/v2" | |
| prompt_url SCIM_URL "Workbench SCIM base URL" "$DEFAULT_SCIM_URL" "/scim/v2" | |
| echo "Testing SCIM endpoint reachability..." | |
| if curl -sk --connect-timeout 10 -o /dev/null -w '' "$SCIM_URL" 2>/dev/null; then | |
| echo "SCIM endpoint is reachable." | |
| else | |
| echo "WARNING: SCIM endpoint at $SCIM_URL is not reachable from this environment." | |
| yesno SCIM_CONNECTIVITY_CONFIRMED "Do you have connectivity between Azure and your Workbench instance handled via another avenue (e.g., VPN, private endpoint)?" "No" | |
| if [[ "$SCIM_CONNECTIVITY_CONFIRMED" != "Yes" ]]; then | |
| echo "Skipping SCIM provisioning. SCIM requires network connectivity from Azure to your Workbench instance." | |
| CREATE_SCIM="No" | |
| fi | |
| fi | |
| if [[ "$CREATE_SCIM" == "Yes" ]]; then | |
| prompt SCIM_TOKEN "Workbench SCIM bearer token" "" true | |
| yesno START_SCIM "Start SCIM provisioning job now?" "No" | |
| fi | |
| fi | |
| # --- Create app registration --- | |
| if [[ "$CREATE_SCIM" == "Yes" ]]; then | |
| echo "Creating unified OIDC+SCIM app from Microsoft template..." | |
| TEMPLATE_JSON="$(az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applicationTemplates/$SCIM_TEMPLATE_ID/instantiate" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg name "$APP_NAME" '{displayName: $name}')" \ | |
| -o json)" | |
| SP_OBJECT_ID="$(jq -r '.servicePrincipal.id // empty' <<<"$TEMPLATE_JSON")" | |
| CLIENT_ID="$(jq -r '.application.appId // empty' <<<"$TEMPLATE_JSON")" | |
| if [[ -z "$SP_OBJECT_ID" ]]; then | |
| echo "Template instantiation did not return a service principal ID." | |
| echo "$TEMPLATE_JSON" | |
| exit 1 | |
| fi | |
| echo "Waiting for service principal to become available..." | |
| for i in $(seq 1 12); do | |
| if az ad sp show --id "$SP_OBJECT_ID" -o none 2>/dev/null; then break; fi | |
| if (( i == 12 )); then | |
| echo "Timed out waiting for service principal $SP_OBJECT_ID to become available." >&2 | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| APP_OBJECT_ID="$(az ad app show --id "$CLIENT_ID" --query id -o tsv)" | |
| echo "Configuring app registration with OIDC settings..." | |
| az rest --method PATCH \ | |
| --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n \ | |
| --arg audience "$SIGNIN_AUDIENCE" \ | |
| --arg uri "$REDIRECT_URI" \ | |
| --arg groups "$GROUP_MEMBERSHIP_CLAIMS" \ | |
| '{ | |
| signInAudience: $audience, | |
| groupMembershipClaims: $groups, | |
| web: { | |
| redirectUris: [$uri], | |
| implicitGrantSettings: { | |
| enableIdTokenIssuance: true, | |
| enableAccessTokenIssuance: false | |
| } | |
| }, | |
| optionalClaims: { | |
| idToken: [ | |
| {name: "email", essential: false}, | |
| {name: "preferred_username", essential: false} | |
| ] | |
| } | |
| }')" \ | |
| >/dev/null | |
| else | |
| echo "Creating OIDC app registration..." | |
| APP_JSON="$(az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applications" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n \ | |
| --arg name "$APP_NAME" \ | |
| --arg audience "$SIGNIN_AUDIENCE" \ | |
| --arg uri "$REDIRECT_URI" \ | |
| --arg groups "$GROUP_MEMBERSHIP_CLAIMS" \ | |
| '{ | |
| displayName: $name, | |
| signInAudience: $audience, | |
| groupMembershipClaims: $groups, | |
| web: { | |
| redirectUris: [$uri], | |
| implicitGrantSettings: { | |
| enableIdTokenIssuance: true, | |
| enableAccessTokenIssuance: false | |
| } | |
| }, | |
| optionalClaims: { | |
| idToken: [ | |
| {name: "email", essential: false}, | |
| {name: "preferred_username", essential: false} | |
| ] | |
| } | |
| }')" \ | |
| -o json)" | |
| APP_OBJECT_ID="$(jq -r '.id' <<<"$APP_JSON")" | |
| CLIENT_ID="$(jq -r '.appId' <<<"$APP_JSON")" | |
| fi | |
| set_app_logo "$APP_OBJECT_ID" | |
| echo "Adding OpenID delegated permissions..." | |
| if ! perm_output="$(az ad app permission add --id "$CLIENT_ID" --api "$GRAPH_APP_ID" --api-permissions \ | |
| "37f7f235-527c-4136-accd-4a02d197296e=Scope" \ | |
| "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0=Scope" \ | |
| "14dad69e-099b-42c9-810b-d002981feec1=Scope" \ | |
| "7427e0e9-2fba-42fe-b0c0-848c9e6a818b=Scope" \ | |
| "e1fe6dd8-ba31-4d61-89e7-88639da4683d=Scope" 2>&1)"; then | |
| if [[ "$perm_output" != *"already exist"* ]]; then | |
| echo "Failed to add permissions: $perm_output" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| echo "Creating client secret..." | |
| SECRET_JSON="$(az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID/addPassword" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg name "$CLIENT_SECRET_NAME" \ | |
| '{passwordCredential: {displayName: $name}}')" \ | |
| -o json 2>/dev/null)" | |
| CLIENT_SECRET="$(jq -r '.secretText' <<<"$SECRET_JSON")" | |
| echo "Adding signed-in user as app owner..." | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID/owners/\$ref" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg id "https://graph.microsoft.com/v1.0/directoryObjects/$SIGNED_IN_USER" \ | |
| '{"@odata.id": $id}')" >/dev/null 2>&1 || true | |
| if [[ -z "${SP_OBJECT_ID:-}" ]]; then | |
| echo "Creating/ensuring enterprise service principal..." | |
| az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true | |
| for i in $(seq 1 6); do | |
| SP_OBJECT_ID="$(az ad sp show --id "$CLIENT_ID" --query id -o tsv 2>/dev/null)" && [[ -n "$SP_OBJECT_ID" ]] && break | |
| if (( i == 6 )); then | |
| echo "Timed out waiting for service principal for $CLIENT_ID to become available." >&2 | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| fi | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/owners/\$ref" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg id "https://graph.microsoft.com/v1.0/directoryObjects/$SIGNED_IN_USER" \ | |
| '{"@odata.id": $id}')" >/dev/null 2>&1 || true | |
| echo "Granting admin consent for delegated permissions..." | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n \ | |
| --arg clientId "$SP_OBJECT_ID" \ | |
| --arg resourceId "$GRAPH_SP_ID" \ | |
| '{ | |
| clientId: $clientId, | |
| consentType: "AllPrincipals", | |
| resourceId: $resourceId, | |
| scope: "email offline_access openid profile User.Read" | |
| }')" \ | |
| -o json >/dev/null | |
| echo "Requiring user assignment on enterprise app..." | |
| az rest --method PATCH \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n '{appRoleAssignmentRequired: true}')" \ | |
| >/dev/null | |
| echo "Assigning signed-in user to enterprise app..." | |
| APP_ROLE_ID="$(az rest --method GET \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID" \ | |
| -o json | jq -r '[.appRoles[] | select(.isEnabled == true)] | if length > 0 then .[0].id else "00000000-0000-0000-0000-000000000000" end')" | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SP_OBJECT_ID/appRoleAssignedTo" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n \ | |
| --arg principalId "$SIGNED_IN_USER" \ | |
| --arg resourceId "$SP_OBJECT_ID" \ | |
| --arg appRoleId "$APP_ROLE_ID" \ | |
| '{ | |
| principalId: $principalId, | |
| resourceId: $resourceId, | |
| appRoleId: $appRoleId | |
| }')" \ | |
| >/dev/null | |
| else | |
| prompt_url BASE_URL "$PRODUCT_LABEL base URL" "$URL_EXAMPLE" | |
| APP_NAME="${APP_NAME:-$DEFAULT_APP_NAME}" | |
| fi | |
| # --- SCIM provisioning (Workbench only) --- | |
| SCIM_OUTPUT="" | |
| if [[ "$CREATE_SCIM" == "Yes" ]]; then | |
| if [[ "$SKIP_OIDC" == "Yes" ]]; then | |
| # Mode 3 (SCIM only): standalone SCIM app | |
| DEFAULT_SCIM_APP_NAME="$(truncate_name "$APP_NAME" "-scim-provisioning" 120)" | |
| DEFAULT_SCIM_URL="${BASE_URL%/}/scim/v2" | |
| prompt SCIM_APP_NAME "SCIM enterprise app name" "$DEFAULT_SCIM_APP_NAME" | |
| prompt_url SCIM_URL "Workbench SCIM base URL" "$DEFAULT_SCIM_URL" "/scim/v2" | |
| echo "Testing SCIM endpoint reachability..." | |
| if curl -sk --connect-timeout 10 -o /dev/null -w '' "$SCIM_URL" 2>/dev/null; then | |
| echo "SCIM endpoint is reachable." | |
| else | |
| echo "WARNING: SCIM endpoint at $SCIM_URL is not reachable from this environment." | |
| yesno SCIM_CONNECTIVITY_CONFIRMED "Do you have connectivity between Azure and your Workbench instance handled via another avenue (e.g., VPN, private endpoint)?" "No" | |
| if [[ "$SCIM_CONNECTIVITY_CONFIRMED" != "Yes" ]]; then | |
| echo "Skipping SCIM provisioning. SCIM requires network connectivity from Azure to your Workbench instance." | |
| CREATE_SCIM="No" | |
| fi | |
| fi | |
| fi | |
| fi | |
| if [[ "$CREATE_SCIM" == "Yes" ]]; then | |
| if [[ "$SKIP_OIDC" == "Yes" ]]; then | |
| # Mode 3: collect remaining prompts and create standalone SCIM app | |
| prompt SCIM_TOKEN "Workbench SCIM bearer token" "" true | |
| yesno START_SCIM "Start SCIM provisioning job now?" "No" | |
| echo "Creating SCIM enterprise application from Microsoft template..." | |
| SCIM_APP_JSON="$(az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applicationTemplates/$SCIM_TEMPLATE_ID/instantiate" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg name "$SCIM_APP_NAME" '{displayName: $name}')" \ | |
| -o json)" | |
| SCIM_SP_ID="$(jq -r '.servicePrincipal.id // empty' <<<"$SCIM_APP_JSON")" | |
| SCIM_APP_ID="$(jq -r '.application.appId // empty' <<<"$SCIM_APP_JSON")" | |
| if [[ -z "$SCIM_SP_ID" ]]; then | |
| echo "SCIM application creation did not return a service principal ID." | |
| echo "$SCIM_APP_JSON" | |
| exit 1 | |
| fi | |
| echo "Waiting for SCIM service principal to become available..." | |
| for i in $(seq 1 12); do | |
| if az ad sp show --id "$SCIM_SP_ID" -o none 2>/dev/null; then break; fi | |
| if (( i == 12 )); then | |
| echo "Timed out waiting for service principal $SCIM_SP_ID to become available." >&2 | |
| exit 1 | |
| fi | |
| sleep 5 | |
| done | |
| echo "Adding signed-in user as SCIM app owner..." | |
| SCIM_APP_OBJECT_ID="$(az ad app show --id "$SCIM_APP_ID" --query id -o tsv)" | |
| set_app_logo "$SCIM_APP_OBJECT_ID" | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/applications/$SCIM_APP_OBJECT_ID/owners/\$ref" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg id "https://graph.microsoft.com/v1.0/directoryObjects/$SIGNED_IN_USER" \ | |
| '{"@odata.id": $id}')" >/dev/null 2>&1 || true | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SCIM_SP_ID/owners/\$ref" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg id "https://graph.microsoft.com/v1.0/directoryObjects/$SIGNED_IN_USER" \ | |
| '{"@odata.id": $id}')" >/dev/null 2>&1 || true | |
| else | |
| # Mode 1 (OIDC+SCIM unified): reuse the already-created SP | |
| SCIM_SP_ID="$SP_OBJECT_ID" | |
| fi | |
| echo "Waiting for provisioning readiness..." | |
| sleep 10 | |
| echo "Creating SCIM provisioning job..." | |
| JOB_JSON="$(az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SCIM_SP_ID/synchronization/jobs" \ | |
| --headers "Content-Type=application/json" \ | |
| --body '{"templateId":"scim"}' \ | |
| -o json)" | |
| SCIM_JOB_ID="$(jq -r '.id // empty' <<<"$JOB_JSON")" | |
| if [[ -z "$SCIM_JOB_ID" ]]; then | |
| echo "SCIM provisioning job creation did not return a job ID." | |
| echo "$JOB_JSON" | |
| exit 1 | |
| fi | |
| echo "Saving SCIM endpoint and token..." | |
| az rest --method PUT \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SCIM_SP_ID/synchronization/secrets" \ | |
| --headers "Content-Type=application/json" \ | |
| --body "$(jq -n --arg url "$SCIM_URL" --arg token "$SCIM_TOKEN" '{ | |
| value: [ | |
| {key: "BaseAddress", value: $url}, | |
| {key: "SecretToken", value: $token} | |
| ] | |
| }')" >/dev/null | |
| if [[ "$START_SCIM" == "Yes" ]]; then | |
| echo "Starting SCIM provisioning job..." | |
| az rest --method POST \ | |
| --url "https://graph.microsoft.com/v1.0/servicePrincipals/$SCIM_SP_ID/synchronization/jobs/$SCIM_JOB_ID/start" \ | |
| >/dev/null | |
| fi | |
| if [[ "$SKIP_OIDC" != "Yes" ]]; then | |
| SCIM_OUTPUT=" | |
| # SCIM Provisioning (same app): | |
| # Provisioning job ID: $SCIM_JOB_ID | |
| # SCIM URL: $SCIM_URL | |
| " | |
| else | |
| SCIM_OUTPUT=" | |
| # SCIM Enterprise App: | |
| # Display name: $SCIM_APP_NAME | |
| # App/client ID: $SCIM_APP_ID | |
| # Service principal: $SCIM_SP_ID | |
| # Provisioning job ID: $SCIM_JOB_ID | |
| # SCIM URL: $SCIM_URL | |
| # Enterprise App: https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$SCIM_SP_ID/appId/$SCIM_APP_ID | |
| " | |
| fi | |
| fi | |
| # --- Output configuration commands --- | |
| ISSUER="https://login.microsoftonline.com/$TENANT_ID/v2.0" | |
| emit_workbench_commands() { | |
| cat <<EOF | |
| # Append OIDC settings to rserver.conf | |
| cat >> /etc/rstudio/rserver.conf <<'RSERVER' | |
| # --- Entra ID OpenID Connect --- | |
| auth-openid=1 | |
| auth-openid-issuer=$ISSUER | |
| auth-openid-username-claim=preferred_username | |
| RSERVER | |
| # Create client credentials file | |
| cat > /etc/rstudio/openid-client-secret <<'SECRET' | |
| client-id=$CLIENT_ID | |
| client-secret=$CLIENT_SECRET | |
| SECRET | |
| chmod 0600 /etc/rstudio/openid-client-secret | |
| # Restart Workbench | |
| sudo rstudio-server restart | |
| EOF | |
| } | |
| emit_connect_commands() { | |
| local groups_lines="" | |
| if [[ "$INCLUDE_GROUP_CLAIMS" == "Yes" ]]; then | |
| groups_lines=$'\nGroupsAutoProvision = true\nGroupsClaim = "groups"' | |
| fi | |
| cat <<EOF | |
| # Change auth provider from password to oauth2 | |
| sudo sed -i 's/^Provider = "password"/Provider = "oauth2"/' /etc/rstudio-connect/rstudio-connect.gcfg | |
| # Append OAuth2 settings | |
| cat >> /etc/rstudio-connect/rstudio-connect.gcfg <<'GCFG' | |
| [OAuth2] | |
| ClientId = "$CLIENT_ID" | |
| ClientSecret = "$CLIENT_SECRET" | |
| OpenIDConnectIssuer = "$ISSUER" | |
| RequireUsernameClaim = true | |
| UsernameClaim = "preferred_username"${groups_lines} | |
| GCFG | |
| # Restart Connect | |
| sudo systemctl restart rstudio-connect | |
| EOF | |
| } | |
| emit_packagemanager_commands() { | |
| cat <<EOF | |
| # Set the server address for OIDC callback support | |
| sudo sed -i 's|^; Address = "http://posit-connect.example.com"|Address = "$BASE_URL"|' /etc/rstudio-pm/rstudio-pm.gcfg | |
| # Append OpenID Connect settings | |
| cat >> /etc/rstudio-pm/rstudio-pm.gcfg <<'GCFG' | |
| [OpenIDConnect] | |
| Issuer = "$ISSUER" | |
| ClientId = "$CLIENT_ID" | |
| ClientSecret = "$CLIENT_SECRET" | |
| GCFG | |
| # Restart Package Manager | |
| sudo systemctl restart rstudio-pm | |
| EOF | |
| } | |
| if [[ "$SKIP_OIDC" != "Yes" ]]; then | |
| cat <<EOF | |
| === Entra ID registration complete for $PRODUCT_LABEL === | |
| Tenant ID: $TENANT_ID | |
| Client ID: $CLIENT_ID | |
| Client secret: $CLIENT_SECRET | |
| Redirect URI: $REDIRECT_URI | |
| Issuer: $ISSUER | |
| Enterprise App SP ID: $SP_OBJECT_ID | |
| App Registration: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/$CLIENT_ID | |
| Enterprise App: https://portal.azure.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$SP_OBJECT_ID/appId/$CLIENT_ID | |
| $SCIM_OUTPUT | |
| Run the following commands on your $PRODUCT_LABEL server to configure OIDC: | |
| ========================================================================== | |
| EOF | |
| case "$PRODUCT" in | |
| workbench) emit_workbench_commands ;; | |
| connect) emit_connect_commands ;; | |
| packagemanager) emit_packagemanager_commands ;; | |
| esac | |
| else | |
| cat <<EOF | |
| === SCIM-only configuration complete for $PRODUCT_LABEL === | |
| Tenant ID: $TENANT_ID | |
| $SCIM_OUTPUT | |
| EOF | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment