Last active
August 19, 2024 19:13
-
-
Save jay0lee/69ee1972065ab500befa39cdf6e176cd to your computer and use it in GitHub Desktop.
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 | |
### | |
### Shell script to get an access token for a user | |
### using Google 3-legged OAuth. | |
### | |
### Example to show your quota usage: | |
### | |
### # first run is to authorize. | |
### bash --credentials-file drive-quota.json \ | |
# --scope "https://www.googleapis.com/auth/drive.metadata.readonly" | |
### | |
### # store access token in a variable | |
### token=$(bash 3-legged-oauth.sh \ | |
### --credentials-file drive-quota.json \ | |
### --scope "https://www.googleapis.com/auth/drive.metadata.readonly") | |
### | |
### # call Drive API to get quota. | |
### curl -H "Authorization: Bearer ${token}" \ | |
### https://www.googleapis.com/drive/v3/about?fields=storageQuota | |
### | |
### Arguments: | |
### | |
### --credentials-file <pathtofile> - Required. file where credentials are stored. | |
### | |
### --scope <scopes> - Required on first run authorization. Scope(s) to authorize. Separate scopes by a space. | |
### | |
### --code-challenge-method - Optional. PKCE method to use. Defaults to S256. Also supports plain and none though | |
### they are not secure options. | |
### | |
### --cert <pathtofile> - Optional. file of client certificate for mTLS. If specified --key is required also. | |
### | |
### --key <pathtofile> - Optional. file of client key for mTLS. If specified --cert is required also. | |
### | |
### --debug - Optional. Output verbose curl messages and results of API calls for troubleshooting. | |
### | |
# create your own client id/secret | |
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#creatingcred | |
client_id='978969553067-73svibj9j7d5sbq6c5587qorl7qouesh.apps.googleusercontent.com' | |
client_secret='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' | |
# Takes a JWT token and returns the decoded body. | |
# WARNING - DOES NOT VALIDATE AUTHENTICITY OF JWT | |
# for this scripts purposes this is not an issue | |
# since we are trusting Google server response anyway. | |
decode_jwt() { | |
echo -n "$1" | | |
cut -d "." -f 2 | | |
tr '_-' '/+' | | |
base64 -d 2> /dev/null | |
} | |
urldecode() { | |
: "${*//+/ }" | |
echo -e "${_//%/\\x}" | |
} | |
debug=false | |
code_challenge_method="S256" | |
force_refresh=false | |
ip="" | |
PARAMS="" | |
while (( "$#" )); do | |
case "$1" in | |
--debug) | |
# Shows curl headers and outputs verbose results. | |
debug=true | |
shift | |
;; | |
-c|--credentials-file) | |
# Local credentials file to be used. Should be an OAuth service account | |
# file in JSON format. | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
credentials_file=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
# OAuth scopes to be used. | |
--scope) | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
scope=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
# IP method to use | |
--ip) | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
ip=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
# Code challenge method | |
--code-challenge-method) | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
code_challenge_method=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
--cert) | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
cert=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
--force-refresh) | |
# force access token refresh | |
force_refresh=true | |
shift | |
;; | |
--key) | |
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then | |
key=$2 | |
shift 2 | |
else | |
echo "Error: Argument for $1 is missing" >&2 | |
exit 1 | |
fi | |
;; | |
*) | |
echo "Error: Unsupported argument $1" >&2 | |
exit 1 | |
;; | |
esac | |
done | |
# set positional arguments in their proper place | |
eval set -- "$PARAMS" | |
if ! [ -x "$(command -v jq)" ]; then | |
echo 'Error: jq is not installed and is needed for JSON parsing.' >&2 | |
exit 1 | |
fi | |
if ! [ -x "$(command -v openssl)" ]; then | |
echo 'Error: openssl is not installed and is needed for hashing functions.' >&2 | |
exit 1 | |
fi | |
if ! [ -x "$(command -v base64)" ]; then | |
echo 'Error: base64 is not installed and is needed for base64 decoding.' >&2 | |
exit 1 | |
fi | |
if [ -z ${credentials_file+x} ]; then | |
echo "ERROR: you need to specify --credentials-file" | |
exit 1 | |
fi | |
if [ ! -z ${cert+x} ] && [ -z ${key+x} ]; then | |
token_endpoint="https://oauth2.mtls.googleapis.com/token" | |
curl_mtls_opts=( --cert "$cert" ) | |
elif [ ! -z ${key+x} ] && [ -z ${cert+x} ]; then | |
echo "ERROR: you must also specify --cert when specifying --key." | |
exit 1 | |
elif [ ! -z ${cert+x} ] && [ ! -z ${key+x} ]; then | |
token_endpoint="https://oauth2.mtls.googleapis.com/token" | |
curl_mtls_opts=( --key "$key" --cert "$cert" ) | |
else | |
curl_mtls_opts=() | |
token_endpoint="https://oauth2.googleapis.com/token" | |
fi | |
case $ip in | |
"6" | "v6" | "ipv6") | |
curl_ipver="--ipv6" | |
;; | |
"4" | "v4" | "ipv4") | |
curl_ipver="--ipv4" | |
;; | |
"") | |
curl_ipver="" | |
;; | |
*) | |
echo "ERROR: --ip should be 4 or 6, got ${ip}." | |
exit 3 | |
;; | |
esac | |
if $debug; then | |
curl_verbosity="-v" | |
else | |
curl_verbosity="-s" | |
fi | |
if ! [ -r "$credentials_file" ]; then | |
if [ -z ${scope+x} ]; then | |
echo "ERROR: you need to specify --scope on first run" | |
exit 2 | |
fi | |
redirect_uri="https://localhost:8888" | |
# Form the request URL | |
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#step-2-send-a-request-to-googles-oauth-20-server | |
auth_url="https://accounts.google.com/o/oauth2/v2/auth?client_id=${client_id}&scope=${scope}&response_type=code&redirect_uri=${redirect_uri}" | |
# Generate the PKCE and append to auth_url | |
# https://developers.google.com/identity/protocols/oauth2/native-app#step1-code-verifier | |
case $code_challenge_method in | |
"plain" | "S256") | |
code_verifier=$(openssl rand -base64 32 | tr '/ +\n' '. ~_' | tr -d '=' ) | |
;;& | |
"plain") | |
code_challenge="$code_verifier" | |
;;& | |
"S256") | |
code_challenge=$(printf "%s" "$code_verifier" | \ | |
openssl dgst -sha256 -binary | \ | |
openssl base64 | \ | |
tr '+/' '-_' | \ | |
tr -d '=') | |
;;& | |
"plain" | "S256") | |
auth_url+="&code_challenge_method=${code_challenge_method}&code_challenge=${code_challenge}" | |
;; | |
"none") | |
code_verifier="" | |
;; | |
*) | |
echo "ERROR: --code-challenge-method should be S256, plain or none, got ${code_challenge_method}." | |
exit 3 | |
;; | |
esac | |
echo "Please go to:" | |
echo | |
echo "$auth_url" | |
echo | |
echo "WARNING: after accepting you'll get a PAGE NOT FOUND error in the" | |
echo "browser. Copy the URL of the page here:" | |
read -r auth_code | |
if [[ $auth_code == https* ]]; then | |
auth_code=$(echo "$auth_code" | grep -E -o 'code=([^&]*)') | |
auth_code=${auth_code: 5} | |
fi | |
auth_code=$(urldecode "$auth_code") | |
# exchange authorization code for access and refresh tokens | |
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#exchange-authorization-code | |
json_format="{code: \$code, client_id: \$client_id, client_secret: \$client_secret, redirect_uri: \$redirect_uri, grant_type: \$grant_type" | |
if [ -z "$code_verifier" ]; then | |
verifier_arg=( ) | |
json_format+="}" | |
else | |
verifier_arg=( "--arg" "code_verifier" "$code_verifier" ) | |
json_format+=", code_verifier: \$code_verifier}" | |
fi | |
json_data=$(jq --null-input --compact-output \ | |
--arg code "$auth_code" \ | |
"${verifier_arg[@]}" \ | |
--arg client_id "$client_id" \ | |
--arg client_secret "$client_secret" \ | |
--arg redirect_uri "$redirect_uri" \ | |
--arg grant_type "authorization_code" \ | |
"$json_format") | |
if $debug; then | |
echo "JSON POST data:" | |
echo | |
echo -e "$json_data" | |
echo | |
fi | |
auth_result=$(curl "$curl_verbosity" "${curl_mtls_opts[@]}" "$curl_ipver" "$token_endpoint" \ | |
-H "Content-Type: application/json" \ | |
-d "$json_data") | |
if $debug; then | |
echo "JSON POST response:" | |
echo | |
echo -e "$auth_result" | |
echo | |
fi | |
expires_in=$(jq -r ".expires_in" <<< "$auth_result") | |
if [ -z "$expires_in" ] || [ "$expires_in" == "null" ]; then | |
echo -e "ERROR: Auth code exchange failed:" | |
echo -e "$auth_result" | |
exit 3 | |
fi | |
time_now=$(date +%s) | |
expires_at=$((time_now + expires_in - 60)) | |
id_token=$(jq -r ".id_token" <<< "$auth_result") | |
if [ "$id_token" == "null" ]; then | |
decoded_id_token="{}" | |
else | |
decoded_id_token=$(decode_jwt "$id_token") | |
fi | |
if $debug; then | |
echo "Decoded ID Token:" | |
echo | |
echo -e "$decoded_id_token" | jq . | |
echo | |
fi | |
auth_result=$(echo "$auth_result" | jq --sort-keys \ | |
--argjson decoded_id_token "$decoded_id_token" \ | |
--arg expires_at "$expires_at" \ | |
'. + {expires_at: $expires_at|tonumber, decoded_id_token: $decoded_id_token}') | |
echo -e "$auth_result" > "$credentials_file" | |
fi | |
expires_at="$(jq -r ".expires_at" "${credentials_file}")" | |
access_token="$(jq -r ".access_token" "${credentials_file}")" | |
# if our access token is expired, use the refresh token to get a new one | |
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#offline | |
time_now=$(date +%s) | |
if ( [ $force_refresh = true ] || [ $time_now -gt $expires_at ] ); then | |
refresh_token="$(jq -r ".refresh_token" "${credentials_file}")" | |
json_data=$(jq --null-input --compact-output \ | |
--arg refresh_token "$refresh_token" \ | |
--arg client_id "$client_id" \ | |
--arg client_secret "$client_secret" \ | |
--arg grant_type "refresh_token" \ | |
--arg scope "$scope" \ | |
'{"refresh_token": $refresh_token, "client_id": $client_id, "client_secret": $client_secret, "grant_type": $grant_type, "scope": $scope}') | |
if $debug; then | |
echo "Refresh JSON POST data:" | |
echo | |
echo -e "$json_data" | |
echo | |
fi | |
refresh_result=$(curl "$curl_verbosity" "${curl_mtls_opts[@]}" "$curl_ipver" "$token_endpoint" \ | |
-H "Content-Type: application/json" \ | |
-d "$json_data") | |
access_token=$(jq -r ".access_token" <<< "$refresh_result") | |
if [ -z "$access_token" ] || [ "$access_token" == "null" ]; then | |
echo -e "ERROR: Refresh token failed:" | |
echo -e "$refresh_result" | |
echo | |
exit 3 | |
fi | |
expires_in=$(jq -r ".expires_in" <<< "$refresh_result") | |
expires_at=$((time_now + expires_in - 60)) | |
id_token=$(jq -r ".id_token" <<< "$refresh_result") | |
scope=$(jq -r ".scope" <<< "$refresh_result") | |
if [ "$id_token" == "null" ]; then | |
decoded_id_token="{}" | |
else | |
decoded_id_token=$(decode_jwt "$id_token") | |
fi | |
refreshed_creds=$(jq --arg expires_at "$expires_at" \ | |
--arg access_token "$access_token" \ | |
--arg id_token "$id_token" \ | |
--argjson decoded_id_token "$decoded_id_token" \ | |
--arg scope "$scope" \ | |
'. + {expires_at: $expires_at|tonumber, access_token: $access_token, id_token: $id_token, decoded_id_token: $decoded_id_token, scope: $scope}' \ | |
"$credentials_file") | |
echo -e "$refreshed_creds" > "$credentials_file" | |
fi | |
echo "$access_token" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment