Created
April 16, 2026 16:25
-
-
Save infamousjoeg/450865cfcf878868b2342511c84f2f5b to your computer and use it in GitHub Desktop.
Helper for configuring CyberArk Secrets Manager SaaS authn-jwt when authenticating Azure DevOps pipelines via Workload Identity Federation. #
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 | |
| # | |
| # authn-jwt-ado-helper.sh | |
| # | |
| # Helper for configuring CyberArk Secrets Manager SaaS authn-jwt when | |
| # authenticating Azure DevOps pipelines via Workload Identity Federation. | |
| # | |
| # Given a JWT (either an Azure AD access token or an Azure DevOps OIDC token), | |
| # this script: | |
| # 1. Decodes the header and payload | |
| # 2. Detects the token type (AAD v1, AAD v2, or ADO OIDC) | |
| # 3. Derives the values needed for the authn-jwt authenticator variables | |
| # (issuer, jwks-uri, audience, recommended token-app-property) | |
| # 4. Emits ready-to-paste CLI commands to set those variables | |
| # 5. Emits an example host policy snippet with the correct annotations | |
| # 6. Emits a test curl command to authenticate against Secrets Manager SaaS | |
| # | |
| # Usage: | |
| # # Inspect a token you already have (from an ADO pipeline log, az, etc.) | |
| # ./authn-jwt-ado-helper.sh --jwt <token> | |
| # cat token.txt | ./authn-jwt-ado-helper.sh --jwt - | |
| # | |
| # # Acquire an Azure AD access token from Cloud Shell and inspect it | |
| # ./authn-jwt-ado-helper.sh --aad-audience <appid> [--tenant <id>] | |
| # ./authn-jwt-ado-helper.sh --aad-audience <appid> --client-secret | |
| # | |
| # Environment variables for output shaping (all optional): | |
| # CONJUR_SERVICE_ID authn-jwt service ID default: azure-ado | |
| # CONJUR_ACCOUNT Secrets Manager SaaS account default: conjur | |
| # CONJUR_TENANT SaaS subdomain default: <your-tenant> | |
| # CONJUR_IDENTITY_PATH policy branch for hosts default: data/ado | |
| # | |
| # Requires: jq, base64, tr. Token acquisition also needs az. | |
| set -euo pipefail | |
| CONJUR_SERVICE_ID="${CONJUR_SERVICE_ID:-azure-ado}" | |
| CONJUR_ACCOUNT="${CONJUR_ACCOUNT:-conjur}" | |
| CONJUR_TENANT="${CONJUR_TENANT:-<your-tenant>}" | |
| CONJUR_IDENTITY_PATH="${CONJUR_IDENTITY_PATH:-data/ado}" | |
| MODE="" | |
| JWT_INPUT="" | |
| AAD_AUDIENCE="" | |
| TENANT="" | |
| USE_CLIENT_CRED=false | |
| usage() { | |
| sed -n '2,36p' "$0" | sed 's/^# \{0,1\}//' | |
| exit 1 | |
| } | |
| [[ $# -lt 1 ]] && usage | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --jwt) MODE="jwt"; JWT_INPUT="$2"; shift ;; | |
| --aad-audience) MODE="aad"; AAD_AUDIENCE="$2"; shift ;; | |
| --tenant) TENANT="$2"; shift ;; | |
| --client-secret) USE_CLIENT_CRED=true ;; | |
| -h|--help) usage ;; | |
| *) echo "Unknown flag: $1" >&2; usage ;; | |
| esac | |
| shift | |
| done | |
| b64url_decode() { | |
| local data="$1" | |
| local mod4=$(( ${#data} % 4 )) | |
| if (( mod4 != 0 )); then | |
| data="${data}$(printf '=%.0s' $(seq 1 $((4 - mod4))))" | |
| fi | |
| printf '%s' "$data" | tr '_-' '/+' | base64 -d 2>/dev/null | |
| } | |
| acquire_aad_user_token() { | |
| local resource="$1" | |
| local args=(account get-access-token --resource "$resource" --query accessToken -o tsv) | |
| [[ -n "$TENANT" ]] && args+=(--tenant "$TENANT") | |
| az "${args[@]}" 2>/dev/null || return 1 | |
| } | |
| acquire_aad_client_cred_token() { | |
| local resource="$1" | |
| local secret="${AAD_CLIENT_SECRET:-}" | |
| if [[ -z "$secret" ]]; then | |
| read -rsp "Client secret for $AAD_AUDIENCE: " secret; echo | |
| fi | |
| local tenant="${TENANT:-$(az account show --query tenantId -o tsv)}" | |
| curl -sS -X POST "https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token" \ | |
| -H "Content-Type: application/x-www-form-urlencoded" \ | |
| --data-urlencode "client_id=${AAD_AUDIENCE}" \ | |
| --data-urlencode "client_secret=${secret}" \ | |
| --data-urlencode "grant_type=client_credentials" \ | |
| --data-urlencode "scope=${resource}/.default" \ | |
| | jq -r '.access_token // empty' | |
| } | |
| # ------ Resolve the token ------ | |
| TOKEN="" | |
| case "$MODE" in | |
| jwt) | |
| if [[ "$JWT_INPUT" == "-" ]]; then | |
| TOKEN="$(cat)" | |
| else | |
| TOKEN="$JWT_INPUT" | |
| fi | |
| # strip whitespace / Bearer prefix if someone pasted a header | |
| TOKEN="${TOKEN#Bearer }" | |
| TOKEN="$(printf '%s' "$TOKEN" | tr -d '[:space:]')" | |
| ;; | |
| aad) | |
| echo "Acquiring Azure AD access token for audience: $AAD_AUDIENCE" >&2 | |
| if $USE_CLIENT_CRED; then | |
| TOKEN=$(acquire_aad_client_cred_token "api://${AAD_AUDIENCE}") || true | |
| else | |
| TOKEN=$(acquire_aad_user_token "$AAD_AUDIENCE") \ | |
| || TOKEN=$(acquire_aad_user_token "api://${AAD_AUDIENCE}") \ | |
| || true | |
| fi | |
| ;; | |
| *) usage ;; | |
| esac | |
| [[ -z "$TOKEN" ]] && { echo "ERROR: no token to inspect" >&2; exit 1; } | |
| IFS='.' read -r HDR PAY SIG <<< "$TOKEN" | |
| HDR_JSON=$(b64url_decode "$HDR") | |
| PAY_JSON=$(b64url_decode "$PAY") | |
| if ! echo "$PAY_JSON" | jq -e . >/dev/null 2>&1; then | |
| echo "ERROR: decoded payload is not valid JSON. Did you paste a full 3-part JWT?" >&2 | |
| exit 1 | |
| fi | |
| ISS=$(echo "$PAY_JSON" | jq -r '.iss // empty') | |
| AUD=$(echo "$PAY_JSON" | jq -r '.aud // empty') | |
| TID=$(echo "$PAY_JSON" | jq -r '.tid // empty') | |
| SUB=$(echo "$PAY_JSON" | jq -r '.sub // empty') | |
| OID=$(echo "$PAY_JSON" | jq -r '.oid // empty') | |
| APPID=$(echo "$PAY_JSON" | jq -r '.appid // .azp // empty') | |
| APP_DISPLAYNAME=$(echo "$PAY_JSON" | jq -r '.app_displayname // empty') | |
| # ------ Detect token type and derive JWKS URI + recommended claim ------ | |
| TOKEN_TYPE="unknown" | |
| JWKS_URI="" | |
| RECOMMENDED_CLAIM="" | |
| RECOMMENDED_CLAIM_VALUE="" | |
| if [[ "$ISS" == https://vstoken.dev.azure.com/* ]] \ | |
| || [[ "$ISS" == https://vstoken.actions.githubusercontent.com* ]] \ | |
| || [[ "$ISS" == *vstoken* ]]; then | |
| TOKEN_TYPE="ADO OIDC (federated identity)" | |
| JWKS_URI="${ISS%/}/.well-known/jwks" | |
| RECOMMENDED_CLAIM="sub" | |
| RECOMMENDED_CLAIM_VALUE="$SUB" | |
| elif [[ "$ISS" == https://login.microsoftonline.com/*/v2.0 ]]; then | |
| TOKEN_TYPE="Azure AD v2.0 access token" | |
| JWKS_URI="${ISS%/v2.0}/discovery/v2.0/keys" | |
| if [[ -n "$OID" ]]; then | |
| RECOMMENDED_CLAIM="oid"; RECOMMENDED_CLAIM_VALUE="$OID" | |
| else | |
| RECOMMENDED_CLAIM="sub"; RECOMMENDED_CLAIM_VALUE="$SUB" | |
| fi | |
| elif [[ "$ISS" == https://sts.windows.net/* ]]; then | |
| TOKEN_TYPE="Azure AD v1.0 access token" | |
| JWKS_URI="https://login.microsoftonline.com/${TID}/discovery/v2.0/keys" | |
| if [[ -n "$OID" ]]; then | |
| RECOMMENDED_CLAIM="oid"; RECOMMENDED_CLAIM_VALUE="$OID" | |
| else | |
| RECOMMENDED_CLAIM="sub"; RECOMMENDED_CLAIM_VALUE="$SUB" | |
| fi | |
| fi | |
| # ------ Output ------ | |
| echo | |
| echo "===== JWT Header =====" | |
| echo "$HDR_JSON" | jq . | |
| echo | |
| echo "===== JWT Payload =====" | |
| echo "$PAY_JSON" | jq . | |
| echo | |
| echo "===== Claim Summary =====" | |
| echo "$PAY_JSON" | jq -r ' | |
| { | |
| aud, iss, tid, | |
| appid: (.appid // .azp // "n/a"), | |
| app_displayname: (.app_displayname // "n/a"), | |
| sub, oid, | |
| upn: (.upn // .unique_name // "n/a"), | |
| idtyp: (.idtyp // "user"), | |
| ver, | |
| scp: (.scp // "n/a"), | |
| roles: (.roles // []), | |
| iat: (.iat | todate), | |
| exp: (.exp | todate), | |
| expires_in_min: ((.exp - (now | floor)) / 60 | floor) | |
| }' | |
| echo | |
| echo "===== Detected Token Type =====" | |
| echo "$TOKEN_TYPE" | |
| echo | |
| echo "===== authn-jwt Configuration Values =====" | |
| cat <<EOF | |
| issuer : $ISS | |
| jwks-uri : $JWKS_URI | |
| audience : $AUD | |
| token-app-property : ${RECOMMENDED_CLAIM:-<choose one of: sub, oid, appid>} | |
| recommended value : ${RECOMMENDED_CLAIM_VALUE:-<unknown>} | |
| identity-path : $CONJUR_IDENTITY_PATH | |
| EOF | |
| echo | |
| echo "===== Why these choices =====" | |
| case "$TOKEN_TYPE" in | |
| "ADO OIDC (federated identity)") | |
| cat <<'EOF' | |
| Using sub as token-app-property because the ADO OIDC sub encodes the full | |
| pipeline identity (organization, project, pipeline, environment). That is | |
| exactly the granularity you want to bind a Secrets Manager host to: one host | |
| per pipeline identity, not per user or per org. | |
| The JWKS URI is derived from the issuer by appending /.well-known/jwks. ADO | |
| publishes its signing keys there and rotates them, so point authn-jwt at the | |
| URI, not at pinned public keys. | |
| EOF | |
| ;; | |
| "Azure AD v2.0 access token"|"Azure AD v1.0 access token") | |
| cat <<'EOF' | |
| Using oid as token-app-property when available because it is the stable | |
| object ID of the service principal in the tenant. appid is also stable but | |
| can appear in delegated tokens too, so oid is the cleaner machine identity | |
| anchor. sub is a pairwise identifier that changes per client application, | |
| so avoid it for workload scenarios. | |
| The JWKS URI is Entra ID's standard discovery endpoint for the tenant. | |
| EOF | |
| ;; | |
| *) | |
| cat <<'EOF' | |
| Could not auto-detect the token's issuer pattern. Verify the iss claim and | |
| derive the JWKS URI from the provider's OIDC discovery document (typically | |
| at <issuer>/.well-known/openid-configuration). | |
| EOF | |
| ;; | |
| esac | |
| echo | |
| echo "===== Conjur CLI: Set authenticator variables =====" | |
| cat <<EOF | |
| conjur variable set -i conjur/authn-jwt/${CONJUR_SERVICE_ID}/issuer -v "${ISS}" | |
| conjur variable set -i conjur/authn-jwt/${CONJUR_SERVICE_ID}/jwks-uri -v "${JWKS_URI}" | |
| conjur variable set -i conjur/authn-jwt/${CONJUR_SERVICE_ID}/audience -v "${AUD}" | |
| conjur variable set -i conjur/authn-jwt/${CONJUR_SERVICE_ID}/token-app-property -v "${RECOMMENDED_CLAIM:-sub}" | |
| conjur variable set -i conjur/authn-jwt/${CONJUR_SERVICE_ID}/identity-path -v "${CONJUR_IDENTITY_PATH}" | |
| EOF | |
| echo | |
| echo "===== Example Host Policy =====" | |
| SAFE_HOST_ID=$(printf '%s' "${RECOMMENDED_CLAIM_VALUE:-pipeline-id}" | tr -c 'A-Za-z0-9._-' '-' | sed 's/^-*//;s/-*$//') | |
| cat <<EOF | |
| - !policy | |
| id: ${CONJUR_IDENTITY_PATH#data/} | |
| body: | |
| - !host | |
| id: ${SAFE_HOST_ID} | |
| annotations: | |
| authn-jwt/${CONJUR_SERVICE_ID}/${RECOMMENDED_CLAIM:-sub}: ${RECOMMENDED_CLAIM_VALUE:-<value>} | |
| EOF | |
| echo | |
| echo "===== Test Authentication (curl) =====" | |
| cat <<EOF | |
| # Send the JWT to Secrets Manager SaaS. Success returns a base64-encoded | |
| # Conjur access token that is valid for roughly 8 minutes. | |
| curl -sS -X POST \\ | |
| "https://${CONJUR_TENANT}.secretsmgr.cyberark.cloud/api/authn-jwt/${CONJUR_SERVICE_ID}/${CONJUR_ACCOUNT}/authenticate" \\ | |
| -H "Content-Type: application/x-www-form-urlencoded" \\ | |
| -H "Accept-Encoding: base64" \\ | |
| --data-urlencode "jwt=\$TOKEN" | |
| EOF | |
| echo | |
| echo "===== jwt.ms link (visual inspection) =====" | |
| echo "https://jwt.ms/#access_token=${TOKEN}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment