Setting the file. One moment.
Auth Probe · Turnstile Spin · cloudflare/skills · Skills Docs
ContentsBack to the top of the page 11.11
README
(opens in a new tab)
scripts/ auth-probe.sh
Shell · 285 lines · 11 KB
15 # "account_mismatch" ; $CLOUDFLARE_ACCOUNT_ID is set but is not in the token's accounts list
16 # "network_failure" ; the Edit-scope probe could not reach the Cloudflare API
17 # "upstream_failure" ; the Edit-scope probe returned an unexpected upstream response
18 #
19 # Account enumeration uses `WRANGLER_BIN whoami --json` only when WRANGLER_BIN is
20 # an approved canonical absolute path outside PROJECT_ROOT and WRANGLER_VERSION
21 # matches it exactly. Otherwise the caller must supply $CLOUDFLARE_ACCOUNT_ID.
22 #
23 # Human-readable diagnostics go to stderr.
24
25 set +x
26 set -uo pipefail
27
28 emit () {
29 echo " $1 "
30 exit 0
31 }
32
33 if ! command -v python3 > /dev/null 2>&1 ; then
34 echo "auth-probe: python3 is required but not found in PATH." >&2
35 emit '{"status":"missing_token","reason":"python3_not_available"}'
36 fi
37
38 token = "${ CLOUDFLARE_API_TOKEN :- }"
39 unset CLOUDFLARE_API_TOKEN
40 declared_account = "${ CLOUDFLARE_ACCOUNT_ID :- }"
41
42 if [ -z " $token " ]; then
43 echo "auth-probe: \$ CLOUDFLARE_API_TOKEN is not set." >&2
44 emit '{"status":"missing_token","reason":"no_env_var"}'
45 fi
46 if [[ ! " $token " =~ ^[A-Za-z0-9_-]+$ ]]; then
47 echo "auth-probe: CLOUDFLARE_API_TOKEN has an invalid format." >&2
48 emit '{"status":"missing_token","reason":"invalid_token_format"}'
49 fi
50
51 accounts_json = ""
52 account_count = 0
53
54 if [ -n "${ WRANGLER_BIN :- }" ]; then
55 if [[ " $WRANGLER_BIN " != / * || ! -x " $WRANGLER_BIN " ]]; then
56 echo "auth-probe: WRANGLER_BIN must be an executable absolute path." >&2
57 emit '{"status":"missing_token","reason":"invalid_wrangler_path"}'
58 fi
59
60 wrangler_bin = $( python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' " $WRANGLER_BIN " )
61 if [ " $wrangler_bin " != " $WRANGLER_BIN " ]; then
62 echo "auth-probe: WRANGLER_BIN must be canonical, without symlinks." >&2
63 emit '{"status":"missing_token","reason":"noncanonical_wrangler_path"}'
64 fi
65 if [ -n "${ PROJECT_ROOT :- }" ]; then
66 project_root = $( python3 -I -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' " $PROJECT_ROOT " )
67 if [[ " $wrangler_bin " == " $project_root " || " $wrangler_bin " == " $project_root /" * ]]; then
68 echo "auth-probe: WRANGLER_BIN must be outside PROJECT_ROOT." >&2
69 emit '{"status":"missing_token","reason":"project_local_wrangler"}'
70 fi
71 fi
72 if [ -z "${ WRANGLER_VERSION :- }" ]; then
73 echo "auth-probe: WRANGLER_VERSION is required with WRANGLER_BIN." >&2
74 emit '{"status":"missing_token","reason":"missing_wrangler_version"}'
75 fi
76
77 actual_version = $(
78 " $wrangler_bin " --version 2> /dev/null |
79 python3 -I -c 'import re,sys; m=re.search(r"\b(\d+\.\d+\.\d+)\b", sys.stdin.read()); print(m.group(1) if m else "")'
80 )
81 if [ " $actual_version " != " $WRANGLER_VERSION " ]; then
82 echo "auth-probe: WRANGLER_BIN version does not match WRANGLER_VERSION." >&2
83 emit '{"status":"missing_token","reason":"wrangler_version_mismatch"}'
84 fi
85
86 whoami_json = $(CLOUDFLARE_API_TOKEN = " $token " " $wrangler_bin " whoami --json 2> /dev/null || true )
87 if [ -n " $whoami_json " ] && [ "$( printf '%s' " $whoami_json " | head -c 1 )" = "{" ]; then
88 accounts_json = $( printf '%s' " $whoami_json " | python3 -I -c '
89 import json, sys
90 try:
91 d = json.load(sys.stdin)
92 print(json.dumps(d.get("accounts") or []))
93 except Exception:
94 print("[]")
95 ' )
96 account_count = $( printf '%s' " $accounts_json " | python3 -I -c '
97 import json, sys
98 try:
99 print(len(json.load(sys.stdin)))
100 except Exception:
101 print(0)
102 ' )
103 fi
104 fi
105
106 if [ " $account_count " = "0" ] && [ -n " $declared_account " ]; then
107 # No wrangler, but user gave us an account. Trust it and skip enumeration.
108 accounts_json = "[{ \" id \" :$( python3 -I -c 'import json, sys; print(json.dumps(sys.argv[1]))' " $declared_account ")}]"
109 account_count = 1
110 fi
111
112 if [ " $account_count " = "0" ]; then
113 echo "auth-probe: could not enumerate accounts. Export CLOUDFLARE_ACCOUNT_ID or provide an approved WRANGLER_BIN and WRANGLER_VERSION." >&2
114 emit '{"status":"missing_token","reason":"no_accounts"}'
115 fi
116
117 if [ -n " $declared_account " ]; then
118 in_list = $( printf '%s' " $accounts_json " | python3 -I -c '
119 import json, sys
120 target = sys.argv[1]
121 try:
122 accounts = json.load(sys.stdin)
123 except Exception:
124 print("false"); sys.exit(0)
125 print("true" if any((a or {}).get("id") == target for a in accounts) else "false")
126 ' " $declared_account " )
127 if [ " $in_list " != "true" ]; then
128 echo "auth-probe: \$ CLOUDFLARE_ACCOUNT_ID ( $declared_account ) is not one of the token's accounts." >&2
129 emit "$( python3 -I -c '
130 import json, sys
131 declared, accounts_raw = sys.argv[1], sys.argv[2]
132 try:
133 accounts = json.loads(accounts_raw)
134 except Exception:
135 accounts = []
136 print(json.dumps({"status":"account_mismatch","declared":declared,"accounts":accounts}))
137 ' " $declared_account " " $accounts_json ")"
138 fi
139 account_id = " $declared_account "
140 elif [ " $account_count " = "1" ]; then
141 account_id = $( printf '%s' " $accounts_json " | python3 -I -c '
142 import json, sys
143 try:
144 print(json.load(sys.stdin)[0]["id"])
145 except Exception:
146 print("")
147 ' )
148 if [ -z " $account_id " ]; then
149 echo "auth-probe: accounts list had one entry but no id field." >&2
150 emit '{"status":"missing_token","reason":"malformed_accounts"}'
151 fi
152 else
153 echo "auth-probe: token covers $account_count accounts; ask the user to pick one, then export \$ CLOUDFLARE_ACCOUNT_ID and re-run." >&2
154 emit "$( python3 -I -c '
155 import json, sys
156 try:
157 accounts = json.loads(sys.argv[1])
158 except Exception:
159 accounts = []
160 print(json.dumps({"status":"multiple_accounts","accounts":accounts}))
161 ' " $accounts_json ")"
162 fi
163
164 # Edit-scope probe. A GET /challenges/widgets would authorize a Read-only
165 # token; to verify Edit specifically, POST with an intentionally invalid
166 # payload and interpret the response:
167 # 401 or 403 → token lacks Edit
168 # 200 with success:false, errors[0].code=10000 → token lacks Edit
169 # 400/422 or 200 with validation error codes → Edit scope OK
170 #
171 # The API rejects the empty-name/empty-domains payload with 400 today, so
172 # no widget is created. If validation ever loosens and the probe accidentally
173 # creates one, we detect the returned sitekey and DELETE it as a safety net
174 # so the probe stays side-effect-free.
175 account_enc = $( python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' " $account_id " )
176
177 if ! probe_response = "$(
178 printf 'header = "Authorization: Bearer %s"\n' " $token " |
179 curl --disable --config - --silent --show-error --write-out $' \n %{http_code}' -X POST \
180 "https://api.cloudflare.com/client/v4/accounts/ $account_enc /challenges/widgets" \
181 -H "Content-Type: application/json" \
182 --data '{"name":"","domains":[]}'
183 )" ; then
184 echo "auth-probe: network failure probing Edit scope on account $account_id ." >&2
185 emit '{"status":"network_failure","account_id":"'" $account_id "'"}'
186 fi
187
188 edit_code = "${ probe_response ##* $' \n '}"
189 probe_body = "${ probe_response % $' \n ' * }"
190 probe_output = $( printf '%s' " $probe_body " | python3 -I -c '
191 import json, sys
192 http_code = sys.argv[1]
193 verdict = "unknown"
194 created_sitekey = ""
195 try:
196 raw = sys.stdin.read()
197 data = json.loads(raw) if raw else {}
198 except Exception:
199 data = None
200 if isinstance(data, dict):
201 errors = data.get("errors") or []
202 if not isinstance(errors, list):
203 errors = []
204 first = (errors[0] or {}) if errors else {}
205 if not isinstance(first, dict):
206 first = {}
207 first_code = first.get("code", 0)
208 if http_code in ("401", "403"):
209 verdict = "missing_scope"
210 elif http_code == "200" and data.get("success") is False and first_code == 10000:
211 verdict = "missing_scope"
212 elif http_code in ("400", "422"):
213 verdict = "scope_ok"
214 elif http_code == "200":
215 # Any 200 that got past auth means scope is fine (whether success or not).
216 verdict = "scope_ok"
217 else:
218 verdict = f"unexpected_{http_code}"
219 # Detect accidental widget creation (safety net if API validation ever
220 # accepts the empty-name/empty-domains probe payload).
221 result = data.get("result")
222 if isinstance(result, dict) and data.get("success") is True:
223 sk = result.get("sitekey", "")
224 if isinstance(sk, str) and sk:
225 created_sitekey = sk
226 print(f"{verdict}|{created_sitekey}")
227 ' " $edit_code " )
228 unset probe_body probe_response
229 verdict = "${ probe_output %% | * }"
230 created_sitekey = "${ probe_output #* |}"
231 [ " $created_sitekey " = " $probe_output " ] && created_sitekey = ""
232
233 # If the probe unexpectedly created a widget (API validation loosened),
234 # DELETE it so the probe stays side-effect-free.
235 if [ -n " $created_sitekey " ]; then
236 echo "auth-probe: probe unexpectedly created widget $created_sitekey ; cleaning up..." >&2
237 sk_enc = $( python3 -I -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' " $created_sitekey " )
238 cleanup_code = $(
239 printf 'header = "Authorization: Bearer %s"\n' " $token " |
240 curl --disable --config - --silent --show-error --output /dev/null --write-out "%{http_code}" -X DELETE \
241 "https://api.cloudflare.com/client/v4/accounts/ $account_enc /challenges/widgets/ $sk_enc " || echo "000"
242 )
243 case " $cleanup_code " in
244 2 * ) echo "auth-probe: cleanup DELETE for widget $created_sitekey succeeded (HTTP $cleanup_code )." >&2 ;;
245 *) echo "auth-probe: cleanup DELETE for widget $created_sitekey FAILED (HTTP $cleanup_code ). Please remove it from the Turnstile dashboard manually." >&2 ;;
246 esac
247 fi
248
249 case " $verdict " in
250 scope_ok )
251 emit "$( python3 -I -c '
252 import json, sys
253 account_id, accounts_raw = sys.argv[1], sys.argv[2]
254 try:
255 accounts = json.loads(accounts_raw)
256 except Exception:
257 accounts = []
258 print(json.dumps({"status":"ok","account_id":account_id,"accounts":accounts}))
259 ' " $account_id " " $accounts_json ")"
260 ;;
261 missing_scope )
262 echo "auth-probe: token cannot write /challenges/widgets on account $account_id (HTTP $edit_code ). Missing Account.Turnstile:Edit." >&2
263 emit "$( python3 -I -c '
264 import json, sys
265 account_id, http_code = sys.argv[1], sys.argv[2]
266 try:
267 code_num = int(http_code)
268 except ValueError:
269 code_num = 0
270 print(json.dumps({"status":"missing_scope","account_id":account_id,"http_code":code_num}))
271 ' " $account_id " " $edit_code ")"
272 ;;
273 *)
274 echo "auth-probe: unexpected response probing Edit scope on account $account_id (HTTP $edit_code )." >&2
275 emit "$( python3 -I -c '
276 import json, sys
277 account_id, http_code = sys.argv[1], sys.argv[2]
278 try:
279 code_num = int(http_code)
280 except ValueError:
281 code_num = 0
282 print(json.dumps({"status":"upstream_failure","account_id":account_id,"http_code":code_num}))
283 ' " $account_id " " $edit_code ")"
284 ;;
285 esac