|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +# Usage: |
| 5 | +# api-operations-token.sh /path/to/credentials.json [scopes] [port] |
| 6 | +# |
| 7 | +# Examples: |
| 8 | +# api-operations-token.sh ./client_secret.json \ |
| 9 | +# "https://www.googleapis.com/auth/cloud-platform openid email profile" 8080 |
| 10 | +# api-operations-token.sh ./client_secret.json \ |
| 11 | +# "openid email profile" 8888 |
| 12 | +# |
| 13 | +# Requires: jq, python3, openssl, curl |
| 14 | +# Note: Ensure the chosen port's redirect URI (http://localhost:<port>) is listed under |
| 15 | +# your OAuth client’s "Authorized redirect URIs" in GCP. |
| 16 | + |
| 17 | +CREDS_JSON="${1:-}" |
| 18 | +SCOPES="${2:-https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid}" |
| 19 | +PORT="${3:-8080}" |
| 20 | + |
| 21 | +if [[ -z "${CREDS_JSON}" || ! -f "${CREDS_JSON}" ]]; then |
| 22 | + echo "Error: provide path to your Google OAuth 'web' credentials JSON." >&2 |
| 23 | + exit 1 |
| 24 | +fi |
| 25 | + |
| 26 | +need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1" >&2; exit 1; }; } |
| 27 | +need jq |
| 28 | +need python3 |
| 29 | +need openssl |
| 30 | +need curl |
| 31 | + |
| 32 | +CLIENT_ID="$(jq -r '.web.client_id' "${CREDS_JSON}")" |
| 33 | +CLIENT_SECRET="$(jq -r '.web.client_secret // empty' "${CREDS_JSON}")" |
| 34 | +AUTH_URI="$(jq -r '.web.auth_uri' "${CREDS_JSON}")" |
| 35 | +TOKEN_URI="$(jq -r '.web.token_uri' "${CREDS_JSON}")" |
| 36 | + |
| 37 | +if [[ -z "${CLIENT_ID}" || -z "${AUTH_URI}" || -z "${TOKEN_URI}" ]]; then |
| 38 | + echo "Error: client_id/auth_uri/token_uri not found under .web in ${CREDS_JSON}" >&2 |
| 39 | + exit 1 |
| 40 | +fi |
| 41 | + |
| 42 | +REDIRECT_URI="http://localhost:${PORT}" |
| 43 | + |
| 44 | +# Helpers |
| 45 | +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } |
| 46 | + |
| 47 | +# PKCE + state/nonce |
| 48 | +CODE_VERIFIER="$(openssl rand -base64 64 | tr -d '\n' | tr '+/' '-_' | tr -d '=')" |
| 49 | +CODE_CHALLENGE="$(printf '%s' "${CODE_VERIFIER}" | openssl dgst -binary -sha256 | b64url)" |
| 50 | +STATE="$(openssl rand -hex 16)" |
| 51 | +NONCE="$(openssl rand -hex 16)" |
| 52 | + |
| 53 | +# Tiny one-shot HTTP server to capture ?code= |
| 54 | +TMP_DIR="$(mktemp -d)" |
| 55 | +CODE_FILE="${TMP_DIR}/auth_code.txt" |
| 56 | + |
| 57 | +cat > "${TMP_DIR}/server.py" <<'PY' |
| 58 | +import http.server, socketserver, urllib.parse, sys |
| 59 | +PORT = int(sys.argv[1]); OUT = sys.argv[2] |
| 60 | +class H(http.server.BaseHTTPRequestHandler): |
| 61 | + def do_GET(self): |
| 62 | + p = urllib.parse.urlparse(self.path); q = urllib.parse.parse_qs(p.query) |
| 63 | + code = q.get("code", [""])[0]; state = q.get("state", [""])[0] |
| 64 | + with open(OUT, "w") as f: f.write(code + "\n" + state + "\n") |
| 65 | + self.send_response(200); self.send_header("Content-Type","text/html"); self.end_headers() |
| 66 | + self.wfile.write(b"<html><body><h2>You can close this window.</h2></body></html>") |
| 67 | +with socketserver.TCPServer(("127.0.0.1", PORT), H) as httpd: httpd.handle_request() |
| 68 | +PY |
| 69 | + |
| 70 | +python3 "${TMP_DIR}/server.py" "${PORT}" "${CODE_FILE}" & |
| 71 | +SERVER_PID=$! |
| 72 | + |
| 73 | +cleanup() { |
| 74 | + echo |
| 75 | + echo "Cleaning up local server (PID ${SERVER_PID})..." |
| 76 | + kill "${SERVER_PID}" >/dev/null 2>&1 || true |
| 77 | + rm -rf "${TMP_DIR}" >/dev/null 2>&1 || true |
| 78 | +} |
| 79 | +# Clean up on normal exit, Ctrl+C (INT), termination (TERM), or error (ERR) |
| 80 | +trap cleanup EXIT INT TERM ERR |
| 81 | + |
| 82 | +# Build consent URL |
| 83 | +AUTH_URL="$AUTH_URI?response_type=code&client_id=$(printf %s "${CLIENT_ID}" | jq -sRr @uri)\ |
| 84 | +&redirect_uri=$(printf %s "${REDIRECT_URI}" | jq -sRr @uri)\ |
| 85 | +&scope=$(printf %s "${SCOPES}" | jq -sRr @uri)\ |
| 86 | +&state=${STATE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256\ |
| 87 | +&access_type=offline&prompt=consent&nonce=${NONCE}" |
| 88 | + |
| 89 | +# Open browser |
| 90 | +if command -v open >/dev/null 2>&1; then |
| 91 | + open "${AUTH_URL}" |
| 92 | +elif command -v xdg-open >/dev/null 2>&1; then |
| 93 | + xdg-open "${AUTH_URL}" |
| 94 | +else |
| 95 | + echo "Open this URL in your browser:" |
| 96 | + echo "${AUTH_URL}" |
| 97 | +fi |
| 98 | + |
| 99 | +echo "Waiting for authorization on ${REDIRECT_URI} ..." |
| 100 | +for _ in {1..180}; do [[ -s "${CODE_FILE}" ]] && break; sleep 1; done |
| 101 | +if [[ ! -s "${CODE_FILE}" ]]; then |
| 102 | + echo "Error: no authorization code received." >&2 |
| 103 | + exit 1 |
| 104 | +fi |
| 105 | + |
| 106 | +AUTH_CODE="$(head -n1 "${CODE_FILE}")" |
| 107 | +READ_STATE="$(sed -n '2p' "${CODE_FILE}")" |
| 108 | + |
| 109 | +if [[ "${READ_STATE}" != "${STATE}" ]]; then |
| 110 | + echo "Error: state mismatch. Aborting." >&2 |
| 111 | + exit 1 |
| 112 | +fi |
| 113 | + |
| 114 | +# Exchange code for tokens |
| 115 | +POST_DATA=( |
| 116 | + -d "grant_type=authorization_code" |
| 117 | + -d "code=${AUTH_CODE}" |
| 118 | + -d "redirect_uri=${REDIRECT_URI}" |
| 119 | + -d "client_id=${CLIENT_ID}" |
| 120 | + -d "code_verifier=${CODE_VERIFIER}" |
| 121 | +) |
| 122 | +[[ -n "${CLIENT_SECRET}" ]] && POST_DATA+=(-d "client_secret=${CLIENT_SECRET}") |
| 123 | + |
| 124 | +TOKENS_JSON="$(curl -sS -X POST "${TOKEN_URI}" \ |
| 125 | + -H "Content-Type: application/x-www-form-urlencoded" "${POST_DATA[@]}")" |
| 126 | + |
| 127 | +# Parse |
| 128 | +ACCESS_TOKEN="$(echo "${TOKENS_JSON}" | jq -r '.access_token // empty')" |
| 129 | +EXPIRES_IN="$(echo "${TOKENS_JSON}" | jq -r '.expires_in // empty')" |
| 130 | +REFRESH_TOKEN="$(echo "${TOKENS_JSON}" | jq -r '.refresh_token // empty')" |
| 131 | +TOKEN_TYPE="$(echo "${TOKENS_JSON}" | jq -r '.token_type // empty')" |
| 132 | + |
| 133 | + |
| 134 | +echo |
| 135 | +echo "=== Token Response (trimmed) ===" |
| 136 | +echo "${TOKENS_JSON}" | jq '{access_token, expires_in, token_type, scope, refresh_token: (has("refresh_token"))}' |
| 137 | + |
| 138 | +echo |
| 139 | +echo "Saved:" |
| 140 | +echo " ${BASE}.json # full token response (keep secure)" |
| 141 | +echo " ${BASE}_access.token # access token" |
| 142 | +[[ -n "${REFRESH_TOKEN}" ]] && echo " ${BASE}_refresh.token # refresh token (store securely)" |
| 143 | + |
| 144 | + |
| 145 | +# Exit non-zero if we failed to produce an access token |
| 146 | +if [[ -z "${ACCESS_TOKEN}" ]]; then |
| 147 | + echo "ERROR: access_token is empty. Inspect ${BASE}.json for details." >&2 |
| 148 | + exit 2 |
| 149 | +fi |
0 commit comments