From e973e76114ac8120dcc3335f4d36abae58a3fd0d Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 26 Mar 2026 23:05:38 +0800 Subject: [PATCH 01/15] feat(examples): add bash-cli example using device code flow - Implement OAuth 2.0 Device Authorization Grant (RFC 8628) in pure bash - Support OIDC auto-discovery, token caching, and refresh token rotation - Handle cross-platform date conversion for macOS and Linux - Add bash-cli section to examples README --- README.md | 9 + bash-cli/main.sh | 491 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+) create mode 100755 bash-cli/main.sh diff --git a/README.md b/README.md index 1897f87..d1057b8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,15 @@ curl -H "Authorization: Bearer " http://localhost:8080/api/profile curl -H "Authorization: Bearer " http://localhost:8080/api/data ``` +### Bash CLI — Device Code Authentication + +Uses the Device Authorization Grant (RFC 8628) with only `curl` and `jq`. No SDK or runtime required. + +```bash +cd bash-cli +bash main.sh +``` + ### M2M — Service-to-Service Authentication Uses Client Credentials grant with auto-caching. No user interaction needed. diff --git a/bash-cli/main.sh b/bash-cli/main.sh new file mode 100755 index 0000000..088ff2c --- /dev/null +++ b/bash-cli/main.sh @@ -0,0 +1,491 @@ +#!/usr/bin/env bash +# Bash CLI example using OAuth 2.0 Device Authorization Grant (RFC 8628). +# +# Authenticates via the device code flow (no browser needed on this machine). +# Tokens are cached to ~/.authgate-tokens.json for reuse. +# +# Prerequisites: curl, jq +# +# Usage: +# +# export AUTHGATE_URL=https://auth.example.com +# export CLIENT_ID=your-client-id +# bash main.sh + +set -euo pipefail + +# --- Configuration --- +SCOPE="profile email" +TOKEN_CACHE_FILE="${HOME}/.authgate-tokens.json" + +# --- Global state (populated at runtime) --- +TOKEN_ENDPOINT="" +USERINFO_ENDPOINT="" +DEVICE_AUTH_ENDPOINT="" +TOKENINFO_URL="" + +# Cached/obtained token fields +ACCESS_TOKEN="" +REFRESH_TOKEN="" +TOKEN_TYPE="" +EXPIRES_IN="" +EXPIRES_AT="" +TOKEN_SCOPE="" +ID_TOKEN="" + +# --- Utilities --- + +die() { + echo "Error: $*" >&2 + exit 1 +} + +check_dependencies() { + command -v curl >/dev/null 2>&1 || die "curl is required but not found" + command -v jq >/dev/null 2>&1 || die "jq is required but not found (install: https://jqlang.github.io/jq/)" +} + +mask_token() { + local s="${1:-}" + if [ ${#s} -le 8 ]; then + echo "****" + else + echo "${s:0:8}..." + fi +} + +# Portable epoch → RFC 3339 conversion (macOS + Linux) +epoch_to_rfc3339() { + local epoch="$1" + if date -u -r 0 +%s >/dev/null 2>&1; then + # BSD/macOS date + date -u -r "$epoch" +"%Y-%m-%dT%H:%M:%SZ" + else + # GNU/Linux date + date -u -d "@$epoch" +"%Y-%m-%dT%H:%M:%SZ" + fi +} + +# --- HTTP helpers --- +# Sets global HTTP_STATUS and HTTP_BODY after each call. +HTTP_STATUS="" +HTTP_BODY="" + +# _parse_response RAW_RESPONSE +# Splits curl output (body + status code on last line) into HTTP_BODY and HTTP_STATUS. +_parse_response() { + local raw="$1" + if [ -z "$raw" ]; then + HTTP_STATUS="000" + HTTP_BODY="" + return + fi + HTTP_STATUS=$(echo "$raw" | tail -n1) + HTTP_BODY=$(echo "$raw" | sed '$d') +} + +# http_get URL [HEADER...] +http_get() { + local url="$1" + shift + local -a headers=() + for h in "$@"; do + headers+=(-H "$h") + done + + local response + if [ ${#headers[@]} -gt 0 ]; then + response=$(curl -s -w "\n%{http_code}" "${headers[@]}" "$url") || true + else + response=$(curl -s -w "\n%{http_code}" "$url") || true + fi + + _parse_response "$response" +} + +# http_post URL DATA +http_post() { + local url="$1" + local data="$2" + + local response + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "$data" \ + "$url") || true + + _parse_response "$response" +} + +# --- OIDC Discovery --- + +discover_endpoints() { + local discovery_url="${AUTHGATE_URL%/}/.well-known/openid-configuration" + http_get "$discovery_url" + + if [ "$HTTP_STATUS" = "000" ]; then + die "Cannot connect to ${AUTHGATE_URL} — is the server running?" + fi + if [ "$HTTP_STATUS" != "200" ]; then + die "OIDC discovery failed (HTTP $HTTP_STATUS): $HTTP_BODY" + fi + + local issuer + issuer=$(echo "$HTTP_BODY" | jq -r '.issuer // empty') || die "Failed to parse discovery response" + [ -n "$issuer" ] || die "Discovery response missing 'issuer'" + + TOKEN_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.token_endpoint // empty') + USERINFO_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.userinfo_endpoint // empty') + DEVICE_AUTH_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.device_authorization_endpoint // empty') + + [ -n "$TOKEN_ENDPOINT" ] || die "Discovery response missing 'token_endpoint'" + + # Derive endpoints if not advertised (matching Go SDK behavior) + if [ -z "$DEVICE_AUTH_ENDPOINT" ]; then + DEVICE_AUTH_ENDPOINT="${issuer%/}/oauth/device/code" + fi + + TOKENINFO_URL="${issuer%/}/oauth/tokeninfo" +} + +# --- Token Cache --- + +load_cached_token() { + [ -f "$TOKEN_CACHE_FILE" ] || return 1 + + local entry + entry=$(jq -r --arg cid "$CLIENT_ID" '.data[$cid] // empty' "$TOKEN_CACHE_FILE" 2>/dev/null) || return 1 + [ -n "$entry" ] || return 1 + + # The value is a JSON string (double-encoded by Go/Python SDKs) + local token_json + token_json=$(echo "$entry" | jq -r 'fromjson? // .' 2>/dev/null) || return 1 + + ACCESS_TOKEN=$(echo "$token_json" | jq -r '.access_token // empty') + REFRESH_TOKEN=$(echo "$token_json" | jq -r '.refresh_token // empty') + TOKEN_TYPE=$(echo "$token_json" | jq -r '.token_type // empty') + EXPIRES_AT=$(echo "$token_json" | jq -r '.expires_at // empty') + TOKEN_SCOPE=$(echo "$token_json" | jq -r '.scope // empty') + ID_TOKEN=$(echo "$token_json" | jq -r '.id_token // empty') + EXPIRES_IN=$(echo "$token_json" | jq -r '.expires_in // "0"') + + [ -n "$ACCESS_TOKEN" ] || return 1 + return 0 +} + +save_cached_token() { + local token_obj + token_obj=$(jq -n \ + --arg at "$ACCESS_TOKEN" \ + --arg rt "$REFRESH_TOKEN" \ + --arg tt "$TOKEN_TYPE" \ + --arg ea "$EXPIRES_AT" \ + --arg sc "$TOKEN_SCOPE" \ + --arg id "$ID_TOKEN" \ + --arg cid "$CLIENT_ID" \ + --argjson ei "${EXPIRES_IN:-0}" \ + '{ + access_token: $at, + refresh_token: $rt, + token_type: $tt, + expires_in: $ei, + expires_at: $ea, + scope: $sc, + id_token: $id, + client_id: $cid + }') + + # Double-encode as JSON string (matching Go/Python SDK format) + local encoded + encoded=$(echo "$token_obj" | jq -c '.' | jq -Rs '.') + + local existing='{}' + if [ -f "$TOKEN_CACHE_FILE" ]; then + existing=$(cat "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') + fi + + # Ensure .data exists, then set the entry + local tmp="${TOKEN_CACHE_FILE}.tmp.$$" + echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ + '.data[$cid] = $val' > "$tmp" + + chmod 600 "$tmp" + mv "$tmp" "$TOKEN_CACHE_FILE" +} + +delete_cached_token() { + [ -f "$TOKEN_CACHE_FILE" ] || return 0 + + local tmp="${TOKEN_CACHE_FILE}.tmp.$$" + jq --arg cid "$CLIENT_ID" 'del(.data[$cid])' "$TOKEN_CACHE_FILE" > "$tmp" 2>/dev/null || return 0 + chmod 600 "$tmp" + mv "$tmp" "$TOKEN_CACHE_FILE" +} + +is_token_expired() { + [ -z "$EXPIRES_AT" ] && return 0 # No expiry info → treat as expired + + local expires_epoch now_epoch + + # EXPIRES_AT may be a Unix timestamp or an RFC 3339 string + if [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]]; then + expires_epoch="$EXPIRES_AT" + else + # Try parsing as RFC 3339 + if date -u -r 0 +%s >/dev/null 2>&1; then + # BSD/macOS + expires_epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXPIRES_AT" +%s 2>/dev/null) || return 0 + else + # GNU/Linux + expires_epoch=$(date -u -d "$EXPIRES_AT" +%s 2>/dev/null) || return 0 + fi + fi + + now_epoch=$(date +%s) + [ "$now_epoch" -ge "$expires_epoch" ] +} + +# --- OAuth Flows --- + +refresh_token_request() { + [ -n "$REFRESH_TOKEN" ] || return 1 + + local data="grant_type=refresh_token&refresh_token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}" + http_post "$TOKEN_ENDPOINT" "$data" + + if [ "$HTTP_STATUS" != "200" ]; then + return 1 + fi + + parse_token_response "$HTTP_BODY" +} + +request_device_code() { + local data="client_id=${CLIENT_ID}&scope=${SCOPE}" + http_post "$DEVICE_AUTH_ENDPOINT" "$data" + + if [ "$HTTP_STATUS" != "200" ]; then + local err_desc + err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) + die "Device code request failed (HTTP $HTTP_STATUS): $err_desc" + fi + + DEVICE_CODE=$(echo "$HTTP_BODY" | jq -r '.device_code') + USER_CODE=$(echo "$HTTP_BODY" | jq -r '.user_code') + VERIFICATION_URI=$(echo "$HTTP_BODY" | jq -r '.verification_uri') + VERIFICATION_URI_COMPLETE=$(echo "$HTTP_BODY" | jq -r '.verification_uri_complete // empty') + DEVICE_EXPIRES_IN=$(echo "$HTTP_BODY" | jq -r '.expires_in // 300') + POLL_INTERVAL=$(echo "$HTTP_BODY" | jq -r '.interval // 5') +} + +poll_for_token() { + local deadline=$(($(date +%s) + DEVICE_EXPIRES_IN)) + local data="grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=${DEVICE_CODE}&client_id=${CLIENT_ID}" + + while true; do + sleep "$POLL_INTERVAL" + + local now + now=$(date +%s) + if [ "$now" -ge "$deadline" ]; then + die "Device code expired. Please try again." + fi + + http_post "$TOKEN_ENDPOINT" "$data" + + if [ "$HTTP_STATUS" = "200" ]; then + parse_token_response "$HTTP_BODY" + return 0 + fi + + local error_code + error_code=$(echo "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) + + case "$error_code" in + authorization_pending) + printf "." >&2 + ;; + slow_down) + POLL_INTERVAL=$((POLL_INTERVAL + 5)) + printf "." >&2 + ;; + expired_token) + echo "" >&2 + die "Device code expired. Please try again." + ;; + access_denied) + echo "" >&2 + die "Authorization denied by user." + ;; + *) + echo "" >&2 + local err_desc + err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // empty' 2>/dev/null) + die "Token request failed: $error_code${err_desc:+ - $err_desc}" + ;; + esac + done +} + +parse_token_response() { + local body="$1" + local old_refresh="$REFRESH_TOKEN" + + ACCESS_TOKEN=$(echo "$body" | jq -r '.access_token // empty') + TOKEN_TYPE=$(echo "$body" | jq -r '.token_type // empty') + EXPIRES_IN=$(echo "$body" | jq -r '.expires_in // 0') + TOKEN_SCOPE=$(echo "$body" | jq -r '.scope // empty') + ID_TOKEN=$(echo "$body" | jq -r '.id_token // empty') + + local new_refresh + new_refresh=$(echo "$body" | jq -r '.refresh_token // empty') + REFRESH_TOKEN="${new_refresh:-$old_refresh}" + + # Compute expires_at as Unix epoch + if [ "$EXPIRES_IN" -gt 0 ] 2>/dev/null; then + EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) + else + EXPIRES_AT="" + fi +} + +# --- API Calls --- + +fetch_userinfo() { + http_get "$USERINFO_ENDPOINT" "Authorization: Bearer $ACCESS_TOKEN" + + if [ "$HTTP_STATUS" != "200" ]; then + return 1 + fi + + USER_NAME=$(echo "$HTTP_BODY" | jq -r '.name // empty') + USER_EMAIL=$(echo "$HTTP_BODY" | jq -r '.email // empty') + USER_SUB=$(echo "$HTTP_BODY" | jq -r '.sub // empty') +} + +fetch_tokeninfo() { + http_get "$TOKENINFO_URL" "Authorization: Bearer $ACCESS_TOKEN" + + if [ "$HTTP_STATUS" != "200" ]; then + echo "TokenInfo error: HTTP $HTTP_STATUS" + return 1 + fi + + TI_ACTIVE=$(echo "$HTTP_BODY" | jq -r '.active // empty') + TI_USER_ID=$(echo "$HTTP_BODY" | jq -r '.user_id // empty') + TI_CLIENT_ID=$(echo "$HTTP_BODY" | jq -r '.client_id // empty') + TI_SCOPE=$(echo "$HTTP_BODY" | jq -r '.scope // empty') + TI_SUBJECT_TYPE=$(echo "$HTTP_BODY" | jq -r '.subject_type // empty') + TI_ISS=$(echo "$HTTP_BODY" | jq -r '.iss // empty') + TI_EXP=$(echo "$HTTP_BODY" | jq -r '.exp // empty') +} + +print_token_info() { + local expires_at_display="" + if [ -n "$EXPIRES_AT" ]; then + expires_at_display=$(epoch_to_rfc3339 "$EXPIRES_AT") + fi + + # UserInfo + if fetch_userinfo; then + echo "User: ${USER_NAME} (${USER_EMAIL})" + echo "Subject: ${USER_SUB}" + else + echo "Token: $(mask_token "$ACCESS_TOKEN") (UserInfo error: HTTP $HTTP_STATUS)" + fi + + # Token details + echo "Access Token: $(mask_token "$ACCESS_TOKEN")" + echo "Refresh Token: $(mask_token "$REFRESH_TOKEN")" + echo "Token Type: ${TOKEN_TYPE}" + echo "Expires In: ${EXPIRES_IN}" + echo "Expires At: ${expires_at_display}" + echo "Scope: ${TOKEN_SCOPE}" + echo "ID Token: $(mask_token "$ID_TOKEN")" + + # TokenInfo + if fetch_tokeninfo; then + echo "TokenInfo Active: ${TI_ACTIVE}" + echo "TokenInfo UserID: ${TI_USER_ID}" + echo "TokenInfo ClientID: ${TI_CLIENT_ID}" + echo "TokenInfo Scope: ${TI_SCOPE}" + echo "TokenInfo SubjectType: ${TI_SUBJECT_TYPE}" + echo "TokenInfo Issuer: ${TI_ISS}" + echo "TokenInfo Exp: ${TI_EXP}" + fi +} + +# --- Main --- + +main() { + check_dependencies + + : "${AUTHGATE_URL:?Error: AUTHGATE_URL environment variable is required}" + : "${CLIENT_ID:?Error: CLIENT_ID environment variable is required}" + + discover_endpoints + + local need_auth=true + + # Try cached token + if load_cached_token; then + if ! is_token_expired; then + need_auth=false + elif refresh_token_request; then + need_auth=false + fi + fi + + # Device Code flow + if [ "$need_auth" = true ]; then + request_device_code + + echo "" + echo "To sign in, open the following URL in a browser:" + echo "" + echo " ${VERIFICATION_URI}" + echo "" + echo "Then enter the code: ${USER_CODE}" + echo "" + + if [ -n "${VERIFICATION_URI_COMPLETE:-}" ]; then + echo "Or open directly: ${VERIFICATION_URI_COMPLETE}" + echo "" + fi + + printf "Waiting for authorization" >&2 + poll_for_token + echo "" >&2 + fi + + # Validate token with userinfo; re-auth if invalid + if ! fetch_userinfo; then + echo "Cached token is invalid, re-authenticating..." + + delete_cached_token + request_device_code + + echo "" + echo "To sign in, open the following URL in a browser:" + echo "" + echo " ${VERIFICATION_URI}" + echo "" + echo "Then enter the code: ${USER_CODE}" + echo "" + + if [ -n "${VERIFICATION_URI_COMPLETE:-}" ]; then + echo "Or open directly: ${VERIFICATION_URI_COMPLETE}" + echo "" + fi + + printf "Waiting for authorization" >&2 + poll_for_token + echo "" >&2 + fi + + save_cached_token + print_token_info +} + +main "$@" From 4192f277e24a93feeb9d26b45cd817421730910b Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Thu, 26 Mar 2026 23:13:21 +0800 Subject: [PATCH 02/15] refactor(bash-cli): harden security and reduce process forks - Pass POST data and auth headers via stdin to prevent token leaks in process list - URL-encode all form data values for correctness - Use mktemp for unpredictable temp file paths - Fix cache file permissions on load - Consolidate multi-jq calls into single invocations per function - Use bash parameter expansion in _parse_response hot path - Extract run_device_flow to eliminate duplicated prompt block - Remove redundant fetch_userinfo call from print_token_info - Detect platform date flavor once at startup --- bash-cli/main.sh | 285 ++++++++++++++++++++++++++++------------------- 1 file changed, 168 insertions(+), 117 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 088ff2c..2b63b19 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -24,7 +24,6 @@ USERINFO_ENDPOINT="" DEVICE_AUTH_ENDPOINT="" TOKENINFO_URL="" -# Cached/obtained token fields ACCESS_TOKEN="" REFRESH_TOKEN="" TOKEN_TYPE="" @@ -33,6 +32,12 @@ EXPIRES_AT="" TOKEN_SCOPE="" ID_TOKEN="" +# Detect platform date flavor once at startup +DATE_FLAVOR="gnu" +if date -u -r 0 +%s >/dev/null 2>&1; then + DATE_FLAVOR="bsd" +fi + # --- Utilities --- die() { @@ -45,6 +50,12 @@ check_dependencies() { command -v jq >/dev/null 2>&1 || die "jq is required but not found (install: https://jqlang.github.io/jq/)" } +# URL-encode a string (RFC 3986) +urlencode() { + local string="$1" + printf '%s' "$string" | jq -sRr @uri +} + mask_token() { local s="${1:-}" if [ ${#s} -le 8 ]; then @@ -54,25 +65,19 @@ mask_token() { fi } -# Portable epoch → RFC 3339 conversion (macOS + Linux) epoch_to_rfc3339() { local epoch="$1" - if date -u -r 0 +%s >/dev/null 2>&1; then - # BSD/macOS date + if [ "$DATE_FLAVOR" = "bsd" ]; then date -u -r "$epoch" +"%Y-%m-%dT%H:%M:%SZ" else - # GNU/Linux date date -u -d "@$epoch" +"%Y-%m-%dT%H:%M:%SZ" fi } # --- HTTP helpers --- -# Sets global HTTP_STATUS and HTTP_BODY after each call. HTTP_STATUS="" HTTP_BODY="" -# _parse_response RAW_RESPONSE -# Splits curl output (body + status code on last line) into HTTP_BODY and HTTP_STATUS. _parse_response() { local raw="$1" if [ -z "$raw" ]; then @@ -80,22 +85,22 @@ _parse_response() { HTTP_BODY="" return fi - HTTP_STATUS=$(echo "$raw" | tail -n1) - HTTP_BODY=$(echo "$raw" | sed '$d') + HTTP_STATUS="${raw##*$'\n'}" + HTTP_BODY="${raw%$'\n'*}" } -# http_get URL [HEADER...] +# Passes headers via --config stdin to avoid leaking tokens in process list http_get() { local url="$1" shift - local -a headers=() + local config="" for h in "$@"; do - headers+=(-H "$h") + config+="header = \"${h}\""$'\n' done local response - if [ ${#headers[@]} -gt 0 ]; then - response=$(curl -s -w "\n%{http_code}" "${headers[@]}" "$url") || true + if [ -n "$config" ]; then + response=$(printf '%s' "$config" | curl -s -w "\n%{http_code}" --config - "$url") || true else response=$(curl -s -w "\n%{http_code}" "$url") || true fi @@ -103,16 +108,16 @@ http_get() { _parse_response "$response" } -# http_post URL DATA +# Passes POST data via stdin to avoid leaking tokens in process list http_post() { local url="$1" local data="$2" local response - response=$(curl -s -w "\n%{http_code}" \ + response=$(printf '%s' "$data" | curl -s -w "\n%{http_code}" \ -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "$data" \ + --data-binary @- \ "$url") || true _parse_response "$response" @@ -128,17 +133,26 @@ discover_endpoints() { die "Cannot connect to ${AUTHGATE_URL} — is the server running?" fi if [ "$HTTP_STATUS" != "200" ]; then - die "OIDC discovery failed (HTTP $HTTP_STATUS): $HTTP_BODY" + die "OIDC discovery failed (HTTP $HTTP_STATUS)" fi - local issuer - issuer=$(echo "$HTTP_BODY" | jq -r '.issuer // empty') || die "Failed to parse discovery response" - [ -n "$issuer" ] || die "Discovery response missing 'issuer'" + local fields + fields=$(echo "$HTTP_BODY" | jq -r '[ + .issuer // empty, + .token_endpoint // empty, + .userinfo_endpoint // empty, + .device_authorization_endpoint // empty + ] | join("\n")') || die "Failed to parse discovery response" - TOKEN_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.token_endpoint // empty') - USERINFO_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.userinfo_endpoint // empty') - DEVICE_AUTH_ENDPOINT=$(echo "$HTTP_BODY" | jq -r '.device_authorization_endpoint // empty') + local issuer + { + IFS= read -r issuer + IFS= read -r TOKEN_ENDPOINT + IFS= read -r USERINFO_ENDPOINT + IFS= read -r DEVICE_AUTH_ENDPOINT + } <<< "$fields" + [ -n "$issuer" ] || die "Discovery response missing 'issuer'" [ -n "$TOKEN_ENDPOINT" ] || die "Discovery response missing 'token_endpoint'" # Derive endpoints if not advertised (matching Go SDK behavior) @@ -153,6 +167,7 @@ discover_endpoints() { load_cached_token() { [ -f "$TOKEN_CACHE_FILE" ] || return 1 + chmod 600 "$TOKEN_CACHE_FILE" 2>/dev/null || true local entry entry=$(jq -r --arg cid "$CLIENT_ID" '.data[$cid] // empty' "$TOKEN_CACHE_FILE" 2>/dev/null) || return 1 @@ -162,13 +177,26 @@ load_cached_token() { local token_json token_json=$(echo "$entry" | jq -r 'fromjson? // .' 2>/dev/null) || return 1 - ACCESS_TOKEN=$(echo "$token_json" | jq -r '.access_token // empty') - REFRESH_TOKEN=$(echo "$token_json" | jq -r '.refresh_token // empty') - TOKEN_TYPE=$(echo "$token_json" | jq -r '.token_type // empty') - EXPIRES_AT=$(echo "$token_json" | jq -r '.expires_at // empty') - TOKEN_SCOPE=$(echo "$token_json" | jq -r '.scope // empty') - ID_TOKEN=$(echo "$token_json" | jq -r '.id_token // empty') - EXPIRES_IN=$(echo "$token_json" | jq -r '.expires_in // "0"') + local fields + fields=$(echo "$token_json" | jq -r '[ + .access_token // empty, + .refresh_token // empty, + .token_type // empty, + .expires_at // empty, + .scope // empty, + .id_token // empty, + (.expires_in // 0 | tostring) + ] | join("\n")') || return 1 + + { + IFS= read -r ACCESS_TOKEN + IFS= read -r REFRESH_TOKEN + IFS= read -r TOKEN_TYPE + IFS= read -r EXPIRES_AT + IFS= read -r TOKEN_SCOPE + IFS= read -r ID_TOKEN + IFS= read -r EXPIRES_IN + } <<< "$fields" [ -n "$ACCESS_TOKEN" ] || return 1 return 0 @@ -198,15 +226,13 @@ save_cached_token() { # Double-encode as JSON string (matching Go/Python SDK format) local encoded - encoded=$(echo "$token_obj" | jq -c '.' | jq -Rs '.') + encoded=$(echo "$token_obj" | jq -Rs '.') - local existing='{}' - if [ -f "$TOKEN_CACHE_FILE" ]; then - existing=$(cat "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') - fi + local existing + existing=$(cat "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') - # Ensure .data exists, then set the entry - local tmp="${TOKEN_CACHE_FILE}.tmp.$$" + local tmp + tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ '.data[$cid] = $val' > "$tmp" @@ -217,27 +243,24 @@ save_cached_token() { delete_cached_token() { [ -f "$TOKEN_CACHE_FILE" ] || return 0 - local tmp="${TOKEN_CACHE_FILE}.tmp.$$" - jq --arg cid "$CLIENT_ID" 'del(.data[$cid])' "$TOKEN_CACHE_FILE" > "$tmp" 2>/dev/null || return 0 + local tmp + tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") + jq --arg cid "$CLIENT_ID" 'del(.data[$cid])' "$TOKEN_CACHE_FILE" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } chmod 600 "$tmp" mv "$tmp" "$TOKEN_CACHE_FILE" } is_token_expired() { - [ -z "$EXPIRES_AT" ] && return 0 # No expiry info → treat as expired + [ -z "$EXPIRES_AT" ] && return 0 local expires_epoch now_epoch - # EXPIRES_AT may be a Unix timestamp or an RFC 3339 string if [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]]; then expires_epoch="$EXPIRES_AT" else - # Try parsing as RFC 3339 - if date -u -r 0 +%s >/dev/null 2>&1; then - # BSD/macOS + if [ "$DATE_FLAVOR" = "bsd" ]; then expires_epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$EXPIRES_AT" +%s 2>/dev/null) || return 0 else - # GNU/Linux expires_epoch=$(date -u -d "$EXPIRES_AT" +%s 2>/dev/null) || return 0 fi fi @@ -251,7 +274,7 @@ is_token_expired() { refresh_token_request() { [ -n "$REFRESH_TOKEN" ] || return 1 - local data="grant_type=refresh_token&refresh_token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}" + local data="grant_type=refresh_token&refresh_token=$(urlencode "$REFRESH_TOKEN")&client_id=$(urlencode "$CLIENT_ID")" http_post "$TOKEN_ENDPOINT" "$data" if [ "$HTTP_STATUS" != "200" ]; then @@ -262,7 +285,7 @@ refresh_token_request() { } request_device_code() { - local data="client_id=${CLIENT_ID}&scope=${SCOPE}" + local data="client_id=$(urlencode "$CLIENT_ID")&scope=$(urlencode "$SCOPE")" http_post "$DEVICE_AUTH_ENDPOINT" "$data" if [ "$HTTP_STATUS" != "200" ]; then @@ -271,17 +294,29 @@ request_device_code() { die "Device code request failed (HTTP $HTTP_STATUS): $err_desc" fi - DEVICE_CODE=$(echo "$HTTP_BODY" | jq -r '.device_code') - USER_CODE=$(echo "$HTTP_BODY" | jq -r '.user_code') - VERIFICATION_URI=$(echo "$HTTP_BODY" | jq -r '.verification_uri') - VERIFICATION_URI_COMPLETE=$(echo "$HTTP_BODY" | jq -r '.verification_uri_complete // empty') - DEVICE_EXPIRES_IN=$(echo "$HTTP_BODY" | jq -r '.expires_in // 300') - POLL_INTERVAL=$(echo "$HTTP_BODY" | jq -r '.interval // 5') + local fields + fields=$(echo "$HTTP_BODY" | jq -r '[ + .device_code, + .user_code, + .verification_uri, + .verification_uri_complete // empty, + (.expires_in // 300 | tostring), + (.interval // 5 | tostring) + ] | join("\n")') + + { + IFS= read -r DEVICE_CODE + IFS= read -r USER_CODE + IFS= read -r VERIFICATION_URI + IFS= read -r VERIFICATION_URI_COMPLETE + IFS= read -r DEVICE_EXPIRES_IN + IFS= read -r POLL_INTERVAL + } <<< "$fields" } poll_for_token() { local deadline=$(($(date +%s) + DEVICE_EXPIRES_IN)) - local data="grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=${DEVICE_CODE}&client_id=${CLIENT_ID}" + local data="grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=$(urlencode "$DEVICE_CODE")&client_id=$(urlencode "$CLIENT_ID")" while true; do sleep "$POLL_INTERVAL" @@ -332,17 +367,28 @@ parse_token_response() { local body="$1" local old_refresh="$REFRESH_TOKEN" - ACCESS_TOKEN=$(echo "$body" | jq -r '.access_token // empty') - TOKEN_TYPE=$(echo "$body" | jq -r '.token_type // empty') - EXPIRES_IN=$(echo "$body" | jq -r '.expires_in // 0') - TOKEN_SCOPE=$(echo "$body" | jq -r '.scope // empty') - ID_TOKEN=$(echo "$body" | jq -r '.id_token // empty') + local fields + fields=$(echo "$body" | jq -r '[ + .access_token // empty, + .token_type // empty, + (.expires_in // 0 | tostring), + .scope // empty, + .id_token // empty, + .refresh_token // empty + ] | join("\n")') local new_refresh - new_refresh=$(echo "$body" | jq -r '.refresh_token // empty') + { + IFS= read -r ACCESS_TOKEN + IFS= read -r TOKEN_TYPE + IFS= read -r EXPIRES_IN + IFS= read -r TOKEN_SCOPE + IFS= read -r ID_TOKEN + IFS= read -r new_refresh + } <<< "$fields" + REFRESH_TOKEN="${new_refresh:-$old_refresh}" - # Compute expires_at as Unix epoch if [ "$EXPIRES_IN" -gt 0 ] 2>/dev/null; then EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) else @@ -359,9 +405,18 @@ fetch_userinfo() { return 1 fi - USER_NAME=$(echo "$HTTP_BODY" | jq -r '.name // empty') - USER_EMAIL=$(echo "$HTTP_BODY" | jq -r '.email // empty') - USER_SUB=$(echo "$HTTP_BODY" | jq -r '.sub // empty') + local fields + fields=$(echo "$HTTP_BODY" | jq -r '[ + .name // empty, + .email // empty, + .sub // empty + ] | join("\n")') + + { + IFS= read -r USER_NAME + IFS= read -r USER_EMAIL + IFS= read -r USER_SUB + } <<< "$fields" } fetch_tokeninfo() { @@ -372,30 +427,42 @@ fetch_tokeninfo() { return 1 fi - TI_ACTIVE=$(echo "$HTTP_BODY" | jq -r '.active // empty') - TI_USER_ID=$(echo "$HTTP_BODY" | jq -r '.user_id // empty') - TI_CLIENT_ID=$(echo "$HTTP_BODY" | jq -r '.client_id // empty') - TI_SCOPE=$(echo "$HTTP_BODY" | jq -r '.scope // empty') - TI_SUBJECT_TYPE=$(echo "$HTTP_BODY" | jq -r '.subject_type // empty') - TI_ISS=$(echo "$HTTP_BODY" | jq -r '.iss // empty') - TI_EXP=$(echo "$HTTP_BODY" | jq -r '.exp // empty') + local fields + fields=$(echo "$HTTP_BODY" | jq -r '[ + (.active // empty | tostring), + .user_id // empty, + .client_id // empty, + .scope // empty, + .subject_type // empty, + .iss // empty, + (.exp // empty | tostring) + ] | join("\n")') + + { + IFS= read -r TI_ACTIVE + IFS= read -r TI_USER_ID + IFS= read -r TI_CLIENT_ID + IFS= read -r TI_SCOPE + IFS= read -r TI_SUBJECT_TYPE + IFS= read -r TI_ISS + IFS= read -r TI_EXP + } <<< "$fields" } +# Uses already-fetched USER_NAME/USER_EMAIL/USER_SUB if available print_token_info() { local expires_at_display="" if [ -n "$EXPIRES_AT" ]; then expires_at_display=$(epoch_to_rfc3339 "$EXPIRES_AT") fi - # UserInfo - if fetch_userinfo; then + if [ -n "${USER_SUB:-}" ]; then echo "User: ${USER_NAME} (${USER_EMAIL})" echo "Subject: ${USER_SUB}" else echo "Token: $(mask_token "$ACCESS_TOKEN") (UserInfo error: HTTP $HTTP_STATUS)" fi - # Token details echo "Access Token: $(mask_token "$ACCESS_TOKEN")" echo "Refresh Token: $(mask_token "$REFRESH_TOKEN")" echo "Token Type: ${TOKEN_TYPE}" @@ -404,7 +471,6 @@ print_token_info() { echo "Scope: ${TOKEN_SCOPE}" echo "ID Token: $(mask_token "$ID_TOKEN")" - # TokenInfo if fetch_tokeninfo; then echo "TokenInfo Active: ${TI_ACTIVE}" echo "TokenInfo UserID: ${TI_USER_ID}" @@ -418,6 +484,27 @@ print_token_info() { # --- Main --- +run_device_flow() { + request_device_code + + echo "" + echo "To sign in, open the following URL in a browser:" + echo "" + echo " ${VERIFICATION_URI}" + echo "" + echo "Then enter the code: ${USER_CODE}" + echo "" + + if [ -n "${VERIFICATION_URI_COMPLETE:-}" ]; then + echo "Or open directly: ${VERIFICATION_URI_COMPLETE}" + echo "" + fi + + printf "Waiting for authorization" >&2 + poll_for_token + echo "" >&2 +} + main() { check_dependencies @@ -428,7 +515,6 @@ main() { local need_auth=true - # Try cached token if load_cached_token; then if ! is_token_expired; then need_auth=false @@ -437,51 +523,16 @@ main() { fi fi - # Device Code flow if [ "$need_auth" = true ]; then - request_device_code - - echo "" - echo "To sign in, open the following URL in a browser:" - echo "" - echo " ${VERIFICATION_URI}" - echo "" - echo "Then enter the code: ${USER_CODE}" - echo "" - - if [ -n "${VERIFICATION_URI_COMPLETE:-}" ]; then - echo "Or open directly: ${VERIFICATION_URI_COMPLETE}" - echo "" - fi - - printf "Waiting for authorization" >&2 - poll_for_token - echo "" >&2 + run_device_flow fi - # Validate token with userinfo; re-auth if invalid + # Validate token with userinfo; re-auth if server-side invalid if ! fetch_userinfo; then echo "Cached token is invalid, re-authenticating..." - delete_cached_token - request_device_code - - echo "" - echo "To sign in, open the following URL in a browser:" - echo "" - echo " ${VERIFICATION_URI}" - echo "" - echo "Then enter the code: ${USER_CODE}" - echo "" - - if [ -n "${VERIFICATION_URI_COMPLETE:-}" ]; then - echo "Or open directly: ${VERIFICATION_URI_COMPLETE}" - echo "" - fi - - printf "Waiting for authorization" >&2 - poll_for_token - echo "" >&2 + run_device_flow + fetch_userinfo || true fi save_cached_token From 01d0f45f3db72660fd63b2aa5352951694890c74 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 12:36:09 +0800 Subject: [PATCH 03/15] fix(bash-cli): harden error handling for Copilot review comments --- bash-cli/main.sh | 48 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 2b63b19..20b4045 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -160,6 +160,12 @@ discover_endpoints() { DEVICE_AUTH_ENDPOINT="${issuer%/}/oauth/device/code" fi + # userinfo_endpoint is optional in OIDC; warn so callers know token + # validation via UserInfo will be skipped. + if [ -z "$USERINFO_ENDPOINT" ]; then + echo "Warning: userinfo_endpoint not found in OIDC discovery; token validation via UserInfo will be skipped." >&2 + fi + TOKENINFO_URL="${issuer%/}/oauth/tokeninfo" } @@ -228,11 +234,15 @@ save_cached_token() { local encoded encoded=$(echo "$token_obj" | jq -Rs '.') + # Treat missing or corrupted cache as empty; fall back to {} local existing - existing=$(cat "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') + existing=$(jq '.' "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') local tmp tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") + # shellcheck disable=SC2064 + trap "rm -f '$tmp'" RETURN + echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ '.data[$cid] = $val' > "$tmp" @@ -290,19 +300,21 @@ request_device_code() { if [ "$HTTP_STATUS" != "200" ]; then local err_desc - err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) + err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) || err_desc="unknown error" die "Device code request failed (HTTP $HTTP_STATUS): $err_desc" fi local fields - fields=$(echo "$HTTP_BODY" | jq -r '[ + if ! fields=$(echo "$HTTP_BODY" | jq -r '[ .device_code, .user_code, .verification_uri, .verification_uri_complete // empty, (.expires_in // 300 | tostring), (.interval // 5 | tostring) - ] | join("\n")') + ] | join("\n")' 2>/dev/null); then + die "Failed to parse device code response (invalid or non-JSON body)" + fi { IFS= read -r DEVICE_CODE @@ -312,6 +324,12 @@ request_device_code() { IFS= read -r DEVICE_EXPIRES_IN IFS= read -r POLL_INTERVAL } <<< "$fields" + + [ -n "$DEVICE_CODE" ] || die "Device code response missing 'device_code'" + [ -n "$USER_CODE" ] || die "Device code response missing 'user_code'" + [ -n "$VERIFICATION_URI" ] || die "Device code response missing 'verification_uri'" + [[ "$DEVICE_EXPIRES_IN" =~ ^[0-9]+$ ]] || DEVICE_EXPIRES_IN=300 + [[ "$POLL_INTERVAL" =~ ^[0-9]+$ ]] || POLL_INTERVAL=5 } poll_for_token() { @@ -335,7 +353,7 @@ poll_for_token() { fi local error_code - error_code=$(echo "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) + error_code=$(echo "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) || error_code="unknown" case "$error_code" in authorization_pending) @@ -356,7 +374,7 @@ poll_for_token() { *) echo "" >&2 local err_desc - err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // empty' 2>/dev/null) + err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // empty' 2>/dev/null) || err_desc="" die "Token request failed: $error_code${err_desc:+ - $err_desc}" ;; esac @@ -389,6 +407,9 @@ parse_token_response() { REFRESH_TOKEN="${new_refresh:-$old_refresh}" + [ -n "$ACCESS_TOKEN" ] || die "Token response missing 'access_token'" + [ -n "$TOKEN_TYPE" ] || die "Token response missing 'token_type'" + if [ "$EXPIRES_IN" -gt 0 ] 2>/dev/null; then EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN )) else @@ -527,12 +548,15 @@ main() { run_device_flow fi - # Validate token with userinfo; re-auth if server-side invalid - if ! fetch_userinfo; then - echo "Cached token is invalid, re-authenticating..." - delete_cached_token - run_device_flow - fetch_userinfo || true + # Validate token with userinfo; re-auth if server-side invalid. + # Skip if userinfo_endpoint was not advertised by the server. + if [ -n "$USERINFO_ENDPOINT" ]; then + if ! fetch_userinfo; then + echo "Cached token is invalid, re-authenticating..." + delete_cached_token + run_device_flow + fetch_userinfo || true + fi fi save_cached_token From 9b06b3d5a2ce10978fe30b76804ac336e89a7b09 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:24:04 +0800 Subject: [PATCH 04/15] fix(bash-cli): address remaining Copilot review comments --- bash-cli/main.sh | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 20b4045..369ead5 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -240,14 +240,22 @@ save_cached_token() { local tmp tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") - # shellcheck disable=SC2064 - trap "rm -f '$tmp'" RETURN - echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ - '.data[$cid] = $val' > "$tmp" + if ! echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ + '.data[$cid] = $val' > "$tmp"; then + rm -f "$tmp" + return 1 + fi - chmod 600 "$tmp" - mv "$tmp" "$TOKEN_CACHE_FILE" + if ! chmod 600 "$tmp"; then + rm -f "$tmp" + return 1 + fi + + if ! mv "$tmp" "$TOKEN_CACHE_FILE"; then + rm -f "$tmp" + return 1 + fi } delete_cached_token() { @@ -298,6 +306,9 @@ request_device_code() { local data="client_id=$(urlencode "$CLIENT_ID")&scope=$(urlencode "$SCOPE")" http_post "$DEVICE_AUTH_ENDPOINT" "$data" + if [ "$HTTP_STATUS" = "000" ]; then + die "Cannot connect to ${DEVICE_AUTH_ENDPOINT} — is the server running?" + fi if [ "$HTTP_STATUS" != "200" ]; then local err_desc err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) || err_desc="unknown error" @@ -352,6 +363,11 @@ poll_for_token() { return 0 fi + if [ "$HTTP_STATUS" = "000" ]; then + echo "" >&2 + die "Connection error while polling for token — is the server running?" + fi + local error_code error_code=$(echo "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) || error_code="unknown" @@ -386,14 +402,16 @@ parse_token_response() { local old_refresh="$REFRESH_TOKEN" local fields - fields=$(echo "$body" | jq -r '[ + if ! fields=$(echo "$body" | jq -r '[ .access_token // empty, .token_type // empty, (.expires_in // 0 | tostring), .scope // empty, .id_token // empty, .refresh_token // empty - ] | join("\n")') + ] | join("\n")' 2>/dev/null); then + die "Failed to parse token response (invalid or non-JSON body; HTTP $HTTP_STATUS)" + fi local new_refresh { @@ -480,6 +498,8 @@ print_token_info() { if [ -n "${USER_SUB:-}" ]; then echo "User: ${USER_NAME} (${USER_EMAIL})" echo "Subject: ${USER_SUB}" + elif [ -z "$USERINFO_ENDPOINT" ]; then + echo "Token: $(mask_token "$ACCESS_TOKEN") (UserInfo not available)" else echo "Token: $(mask_token "$ACCESS_TOKEN") (UserInfo error: HTTP $HTTP_STATUS)" fi From afceb529b0d913499dbd1f6fac40e16181a17807 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:31:18 +0800 Subject: [PATCH 05/15] fix(bash-cli): replace // empty with // "" in jq array joins to prevent field shifting --- bash-cli/main.sh | 58 ++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 369ead5..c57d439 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -138,10 +138,10 @@ discover_endpoints() { local fields fields=$(echo "$HTTP_BODY" | jq -r '[ - .issuer // empty, - .token_endpoint // empty, - .userinfo_endpoint // empty, - .device_authorization_endpoint // empty + .issuer // "", + .token_endpoint // "", + .userinfo_endpoint // "", + .device_authorization_endpoint // "" ] | join("\n")') || die "Failed to parse discovery response" local issuer @@ -185,12 +185,12 @@ load_cached_token() { local fields fields=$(echo "$token_json" | jq -r '[ - .access_token // empty, - .refresh_token // empty, - .token_type // empty, - .expires_at // empty, - .scope // empty, - .id_token // empty, + .access_token // "", + .refresh_token // "", + .token_type // "", + .expires_at // "", + .scope // "", + .id_token // "", (.expires_in // 0 | tostring) ] | join("\n")') || return 1 @@ -317,10 +317,10 @@ request_device_code() { local fields if ! fields=$(echo "$HTTP_BODY" | jq -r '[ - .device_code, - .user_code, - .verification_uri, - .verification_uri_complete // empty, + .device_code // "", + .user_code // "", + .verification_uri // "", + .verification_uri_complete // "", (.expires_in // 300 | tostring), (.interval // 5 | tostring) ] | join("\n")' 2>/dev/null); then @@ -403,12 +403,12 @@ parse_token_response() { local fields if ! fields=$(echo "$body" | jq -r '[ - .access_token // empty, - .token_type // empty, + .access_token // "", + .token_type // "", (.expires_in // 0 | tostring), - .scope // empty, - .id_token // empty, - .refresh_token // empty + .scope // "", + .id_token // "", + .refresh_token // "" ] | join("\n")' 2>/dev/null); then die "Failed to parse token response (invalid or non-JSON body; HTTP $HTTP_STATUS)" fi @@ -446,9 +446,9 @@ fetch_userinfo() { local fields fields=$(echo "$HTTP_BODY" | jq -r '[ - .name // empty, - .email // empty, - .sub // empty + .name // "", + .email // "", + .sub // "" ] | join("\n")') { @@ -468,13 +468,13 @@ fetch_tokeninfo() { local fields fields=$(echo "$HTTP_BODY" | jq -r '[ - (.active // empty | tostring), - .user_id // empty, - .client_id // empty, - .scope // empty, - .subject_type // empty, - .iss // empty, - (.exp // empty | tostring) + (.active // "" | tostring), + .user_id // "", + .client_id // "", + .scope // "", + .subject_type // "", + .iss // "", + (.exp // "" | tostring) ] | join("\n")') { From 2da964c6018e8058378e9187488a83f39c740cfa Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:35:31 +0800 Subject: [PATCH 06/15] fix(bash-cli): make jq failures in fetch_userinfo and fetch_tokeninfo non-fatal --- bash-cli/main.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index c57d439..9320771 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -449,7 +449,7 @@ fetch_userinfo() { .name // "", .email // "", .sub // "" - ] | join("\n")') + ] | join("\n")') || return 1 { IFS= read -r USER_NAME @@ -475,7 +475,7 @@ fetch_tokeninfo() { .subject_type // "", .iss // "", (.exp // "" | tostring) - ] | join("\n")') + ] | join("\n")') || return 1 { IFS= read -r TI_ACTIVE From c12128fb7f7feea085a19ba195ed48076c9ecd94 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:42:20 +0800 Subject: [PATCH 07/15] fix(bash-cli): escape curl config headers, preserve scope/id_token on refresh --- bash-cli/main.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 9320771..a8c4c7a 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -95,7 +95,10 @@ http_get() { shift local config="" for h in "$@"; do - config+="header = \"${h}\""$'\n' + # Escape backslashes and double-quotes to prevent curl config injection + local escaped_h="${h//\\/\\\\}" + escaped_h="${escaped_h//\"/\\\"}" + config+="header = \"${escaped_h}\""$'\n' done local response @@ -400,6 +403,8 @@ poll_for_token() { parse_token_response() { local body="$1" local old_refresh="$REFRESH_TOKEN" + local old_scope="$TOKEN_SCOPE" + local old_id_token="$ID_TOKEN" local fields if ! fields=$(echo "$body" | jq -r '[ @@ -424,6 +429,9 @@ parse_token_response() { } <<< "$fields" REFRESH_TOKEN="${new_refresh:-$old_refresh}" + # Preserve prior scope/id_token when the server omits them (e.g. refresh responses) + TOKEN_SCOPE="${TOKEN_SCOPE:-$old_scope}" + ID_TOKEN="${ID_TOKEN:-$old_id_token}" [ -n "$ACCESS_TOKEN" ] || die "Token response missing 'access_token'" [ -n "$TOKEN_TYPE" ] || die "Token response missing 'token_type'" From 8d66294543d86b86d266f1ae4e070784ebde01da Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:42:21 +0800 Subject: [PATCH 08/15] docs: update README intro to reflect multi-language examples --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d1057b8..e6a17f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AuthGate Examples -Usage examples for the AuthGate Go SDK. +Multi-language usage examples for AuthGate authentication (Go, Python, Bash). ## Prerequisites From 43e4ec0c90e0c498a59cf48db61f8405e3c73ee9 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:50:08 +0800 Subject: [PATCH 09/15] docs: add Python CLI and Python M2M example sections to README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index e6a17f8..a05f3b3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,26 @@ cd bash-cli bash main.sh ``` +### Python CLI — Device Code Authentication + +Uses the Device Authorization Grant with the AuthGate Python SDK. Tokens are cached to `~/.authgate-tokens.json`. + +```bash +cd python-cli +pip install -r requirements.txt +python main.py +``` + +### Python M2M — Service-to-Service Authentication + +Uses the Client Credentials grant with the AuthGate Python SDK. + +```bash +cd python-m2m +pip install -r requirements.txt +python main.py +``` + ### M2M — Service-to-Service Authentication Uses Client Credentials grant with auto-caching. No user interaction needed. From f5f7e7e22513ec5b90579d47fc2a6f7500da5d0f Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:51:31 +0800 Subject: [PATCH 10/15] fix(bash-cli): strip CR/LF and RFC3339 expires_at --- bash-cli/main.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index a8c4c7a..e11569a 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -95,8 +95,11 @@ http_get() { shift local config="" for h in "$@"; do + # Strip CR/LF to prevent curl config injection via newlines + local clean_h="${h//$'\r'/}" + clean_h="${clean_h//$'\n'/}" # Escape backslashes and double-quotes to prevent curl config injection - local escaped_h="${h//\\/\\\\}" + local escaped_h="${clean_h//\\/\\\\}" escaped_h="${escaped_h//\"/\\\"}" config+="header = \"${escaped_h}\""$'\n' done @@ -500,7 +503,11 @@ fetch_tokeninfo() { print_token_info() { local expires_at_display="" if [ -n "$EXPIRES_AT" ]; then - expires_at_display=$(epoch_to_rfc3339 "$EXPIRES_AT") + if [[ "$EXPIRES_AT" =~ ^[0-9]+$ ]]; then + expires_at_display=$(epoch_to_rfc3339 "$EXPIRES_AT") + else + expires_at_display="$EXPIRES_AT" + fi fi if [ -n "${USER_SUB:-}" ]; then From a676d66addb09e46f9857d70999e5e639b0f134f Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:57:07 +0800 Subject: [PATCH 11/15] docs: fix Python example commands and token cache description --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a05f3b3..50af649 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,11 @@ bash main.sh ### Python CLI — Device Code Authentication -Uses the Device Authorization Grant with the AuthGate Python SDK. Tokens are cached to `~/.authgate-tokens.json`. +Uses the Device Authorization Grant with the AuthGate Python SDK. Tokens are stored in the OS keyring when available, with a fallback cache file at `~/.authgate-tokens.json`. ```bash cd python-cli -pip install -r requirements.txt -python main.py +uv run python main.py ``` ### Python M2M — Service-to-Service Authentication @@ -61,8 +60,7 @@ Uses the Client Credentials grant with the AuthGate Python SDK. ```bash cd python-m2m -pip install -r requirements.txt -python main.py +uv run python main.py ``` ### M2M — Service-to-Service Authentication From ccfa34dc9c4fe86cbd91fc2cc8231d17bf69104e Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 13:58:19 +0800 Subject: [PATCH 12/15] fix(bash-cli): guard chmod/mv in delete_cached_token --- bash-cli/main.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index e11569a..b7d121a 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -270,8 +270,14 @@ delete_cached_token() { local tmp tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") jq --arg cid "$CLIENT_ID" 'del(.data[$cid])' "$TOKEN_CACHE_FILE" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } - chmod 600 "$tmp" - mv "$tmp" "$TOKEN_CACHE_FILE" + if ! chmod 600 "$tmp"; then + rm -f "$tmp" + return 1 + fi + if ! mv "$tmp" "$TOKEN_CACHE_FILE"; then + rm -f "$tmp" + return 1 + fi } is_token_expired() { From fb0e91ea495cf1fe51f7da4ee12902c2ceb1f7fd Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 14:12:43 +0800 Subject: [PATCH 13/15] fix(bash-cli): printf for untrusted data, curl timeouts, DATE_FLAVOR detection, non-fatal cache ops --- bash-cli/main.sh | 46 +++++++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index b7d121a..5d3e953 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -32,10 +32,12 @@ EXPIRES_AT="" TOKEN_SCOPE="" ID_TOKEN="" -# Detect platform date flavor once at startup -DATE_FLAVOR="gnu" -if date -u -r 0 +%s >/dev/null 2>&1; then - DATE_FLAVOR="bsd" +# Detect platform date flavor once at startup. +# Probe GNU-style (-d "@0") first to avoid ambiguity with BSD -r 0 +# (which can match a file named "0" in CWD). +DATE_FLAVOR="bsd" +if date -u -d "@0" +%s >/dev/null 2>&1; then + DATE_FLAVOR="gnu" fi # --- Utilities --- @@ -106,9 +108,9 @@ http_get() { local response if [ -n "$config" ]; then - response=$(printf '%s' "$config" | curl -s -w "\n%{http_code}" --config - "$url") || true + response=$(printf '%s' "$config" | curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" --config - "$url") || true else - response=$(curl -s -w "\n%{http_code}" "$url") || true + response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" "$url") || true fi _parse_response "$response" @@ -120,7 +122,7 @@ http_post() { local data="$2" local response - response=$(printf '%s' "$data" | curl -s -w "\n%{http_code}" \ + response=$(printf '%s' "$data" | curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" \ -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-binary @- \ @@ -143,7 +145,7 @@ discover_endpoints() { fi local fields - fields=$(echo "$HTTP_BODY" | jq -r '[ + fields=$(printf '%s' "$HTTP_BODY" | jq -r '[ .issuer // "", .token_endpoint // "", .userinfo_endpoint // "", @@ -187,10 +189,10 @@ load_cached_token() { # The value is a JSON string (double-encoded by Go/Python SDKs) local token_json - token_json=$(echo "$entry" | jq -r 'fromjson? // .' 2>/dev/null) || return 1 + token_json=$(printf '%s' "$entry" | jq -r 'fromjson? // .' 2>/dev/null) || return 1 local fields - fields=$(echo "$token_json" | jq -r '[ + fields=$(printf '%s' "$token_json" | jq -r '[ .access_token // "", .refresh_token // "", .token_type // "", @@ -238,7 +240,7 @@ save_cached_token() { # Double-encode as JSON string (matching Go/Python SDK format) local encoded - encoded=$(echo "$token_obj" | jq -Rs '.') + encoded=$(printf '%s' "$token_obj" | jq -Rs '.') # Treat missing or corrupted cache as empty; fall back to {} local existing @@ -247,7 +249,7 @@ save_cached_token() { local tmp tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") - if ! echo "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ + if ! printf '%s' "$existing" | jq --arg cid "$CLIENT_ID" --argjson val "$encoded" \ '.data[$cid] = $val' > "$tmp"; then rm -f "$tmp" return 1 @@ -323,12 +325,12 @@ request_device_code() { fi if [ "$HTTP_STATUS" != "200" ]; then local err_desc - err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) || err_desc="unknown error" + err_desc=$(printf '%s' "$HTTP_BODY" | jq -r '.error_description // .error // "unknown error"' 2>/dev/null) || err_desc="unknown error" die "Device code request failed (HTTP $HTTP_STATUS): $err_desc" fi local fields - if ! fields=$(echo "$HTTP_BODY" | jq -r '[ + if ! fields=$(printf '%s' "$HTTP_BODY" | jq -r '[ .device_code // "", .user_code // "", .verification_uri // "", @@ -381,7 +383,7 @@ poll_for_token() { fi local error_code - error_code=$(echo "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) || error_code="unknown" + error_code=$(printf '%s' "$HTTP_BODY" | jq -r '.error // "unknown"' 2>/dev/null) || error_code="unknown" case "$error_code" in authorization_pending) @@ -402,7 +404,7 @@ poll_for_token() { *) echo "" >&2 local err_desc - err_desc=$(echo "$HTTP_BODY" | jq -r '.error_description // empty' 2>/dev/null) || err_desc="" + err_desc=$(printf '%s' "$HTTP_BODY" | jq -r '.error_description // empty' 2>/dev/null) || err_desc="" die "Token request failed: $error_code${err_desc:+ - $err_desc}" ;; esac @@ -416,7 +418,7 @@ parse_token_response() { local old_id_token="$ID_TOKEN" local fields - if ! fields=$(echo "$body" | jq -r '[ + if ! fields=$(printf '%s' "$body" | jq -r '[ .access_token // "", .token_type // "", (.expires_in // 0 | tostring), @@ -462,7 +464,7 @@ fetch_userinfo() { fi local fields - fields=$(echo "$HTTP_BODY" | jq -r '[ + fields=$(printf '%s' "$HTTP_BODY" | jq -r '[ .name // "", .email // "", .sub // "" @@ -484,7 +486,7 @@ fetch_tokeninfo() { fi local fields - fields=$(echo "$HTTP_BODY" | jq -r '[ + fields=$(printf '%s' "$HTTP_BODY" | jq -r '[ (.active // "" | tostring), .user_id // "", .client_id // "", @@ -594,13 +596,15 @@ main() { if [ -n "$USERINFO_ENDPOINT" ]; then if ! fetch_userinfo; then echo "Cached token is invalid, re-authenticating..." - delete_cached_token + delete_cached_token || echo "Warning: Failed to delete cached token; continuing with re-auth." >&2 run_device_flow fetch_userinfo || true fi fi - save_cached_token + if ! save_cached_token; then + echo "Warning: Failed to save token cache to ${TOKEN_CACHE_FILE}" >&2 + fi print_token_info } From b3e8bb4aa03b18a6099fd35f11d4d0187f1a50f7 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 14:21:27 +0800 Subject: [PATCH 14/15] fix(bash-cli): symlink check for token cache, curl option injection via -- --- bash-cli/main.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 5d3e953..8e90273 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -108,9 +108,9 @@ http_get() { local response if [ -n "$config" ]; then - response=$(printf '%s' "$config" | curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" --config - "$url") || true + response=$(printf '%s' "$config" | curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" --config - -- "$url") || true else - response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" "$url") || true + response=$(curl -s --connect-timeout 10 --max-time 30 -w "\n%{http_code}" -- "$url") || true fi _parse_response "$response" @@ -126,7 +126,7 @@ http_post() { -X POST \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-binary @- \ - "$url") || true + -- "$url") || true _parse_response "$response" } @@ -181,6 +181,14 @@ discover_endpoints() { load_cached_token() { [ -f "$TOKEN_CACHE_FILE" ] || return 1 + + # Refuse to operate on a symlink or a file not owned by the current user + # to avoid following attacker-controlled links to credential files. + if [ -L "$TOKEN_CACHE_FILE" ] || [ ! -O "$TOKEN_CACHE_FILE" ]; then + echo "Warning: Refusing to use token cache file that is a symlink or not owned by the current user: $TOKEN_CACHE_FILE" >&2 + return 1 + fi + chmod 600 "$TOKEN_CACHE_FILE" 2>/dev/null || true local entry From da034df018ddf87d2e7fb7d0c996d175e0ba4565 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Fri, 27 Mar 2026 14:31:35 +0800 Subject: [PATCH 15/15] fix(bash-cli): symlink/ownership guards for write/delete cache; only re-auth on 401/403 --- bash-cli/main.sh | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/bash-cli/main.sh b/bash-cli/main.sh index 8e90273..d4f5f85 100755 --- a/bash-cli/main.sh +++ b/bash-cli/main.sh @@ -250,6 +250,15 @@ save_cached_token() { local encoded encoded=$(printf '%s' "$token_obj" | jq -Rs '.') + # If the cache file already exists, refuse to operate on a symlink or + # a file not owned by the current user to prevent credential clobbering. + if [ -e "$TOKEN_CACHE_FILE" ]; then + if [ -L "$TOKEN_CACHE_FILE" ] || [ ! -O "$TOKEN_CACHE_FILE" ]; then + echo "Warning: Refusing to write token cache file that is a symlink or not owned by the current user: $TOKEN_CACHE_FILE" >&2 + return 1 + fi + fi + # Treat missing or corrupted cache as empty; fall back to {} local existing existing=$(jq '.' "$TOKEN_CACHE_FILE" 2>/dev/null || echo '{}') @@ -277,6 +286,12 @@ save_cached_token() { delete_cached_token() { [ -f "$TOKEN_CACHE_FILE" ] || return 0 + # Refuse to operate on a symlink or a file not owned by the current user + if [ -L "$TOKEN_CACHE_FILE" ] || [ ! -O "$TOKEN_CACHE_FILE" ]; then + echo "Warning: Refusing to delete token cache file that is a symlink or not owned by the current user: $TOKEN_CACHE_FILE" >&2 + return 0 + fi + local tmp tmp=$(mktemp "${TOKEN_CACHE_FILE}.XXXXXX") jq --arg cid "$CLIENT_ID" 'del(.data[$cid])' "$TOKEN_CACHE_FILE" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 0; } @@ -599,14 +614,18 @@ main() { run_device_flow fi - # Validate token with userinfo; re-auth if server-side invalid. + # Validate token with userinfo; re-auth only on 401/403 (token invalid/expired). # Skip if userinfo_endpoint was not advertised by the server. if [ -n "$USERINFO_ENDPOINT" ]; then if ! fetch_userinfo; then - echo "Cached token is invalid, re-authenticating..." - delete_cached_token || echo "Warning: Failed to delete cached token; continuing with re-auth." >&2 - run_device_flow - fetch_userinfo || true + if [ "$HTTP_STATUS" = "401" ] || [ "$HTTP_STATUS" = "403" ]; then + echo "Cached token is invalid, re-authenticating..." + delete_cached_token || echo "Warning: Failed to delete cached token; continuing with re-auth." >&2 + run_device_flow + fetch_userinfo || true + else + echo "Warning: UserInfo request failed (HTTP $HTTP_STATUS); proceeding with cached token." >&2 + fi fi fi