Skip to content

Instantly share code, notes, and snippets.

@infamousjoeg
Created April 16, 2026 16:25
Show Gist options
  • Select an option

  • Save infamousjoeg/450865cfcf878868b2342511c84f2f5b to your computer and use it in GitHub Desktop.

Select an option

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. #
#!/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