Skip to content

Instantly share code, notes, and snippets.

@jay0lee
Last active November 18, 2022 21:01
Show Gist options
  • Save jay0lee/0b7959b2b17a869cac117af9025af7f4 to your computer and use it in GitHub Desktop.
Save jay0lee/0b7959b2b17a869cac117af9025af7f4 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
###
### PoC to apply Context Aware Access rules (generally IP ranges or geographical regions) to Google API calls.
###
### This script will ultimately generate an access token that can be used to call Workspace APIs as a user.
###
### Example run:
###
### export access_token=$(./dwd-with-caa.sh \
### --credentials-file oauth2service.json \
### --service-account-dwd dwd-service-account@yourproject.iam.gserviceaccount.com \
### --scopes https://www.googleapis.com/auth/gmail.settings.basic \
### --user-dwd a_user@workspace_domain.com)
### curl -s -H "Authorization: Bearer ${access_token}" \
### -H "accept: application/json" \
### https://gmail.googleapis.com/gmail/v1/users/me/settings/imap
###
### Arguments:
###
### --credentials-file <pathtofile> - Required. Either a service account private key in JSON format or a client secrets file.
### these local Credentials will be used by the script to call SignJwt.
###
### --service-account-dwd - Required. The service account which above credentials has rights to call SignJwt for and which has
### been granted DwD access in Workspace. This SHOULD NOT be the same service account as above.
###
### --scopes - Required. OAuth 2.0 scopes which above DwD service account is authorized to use for domain-wide delegation.
###
### --user-dwd - Required. The Google Workspace user whose data should ultimately be accessed by the API.
###
### --debug - Optional. Output verbose curl messages and results of API calls for troubleshooting.
###
### For more details see https://jaylee.us/ku3
###
token_endpoint="https://oauth2.googleapis.com/token"
PARAMS=""
while (( "$#" )); do
case "$1" in
-d|--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 or
# client secrets file for end user credentials.
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 for Domain-Wide Delegation.
-s|--scopes)
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then
scopes=$2
shift 2
else
echo "Error: Argument for $1 is missing" >&2
exit 1
fi
;;
# The service account which the account from --credentials-file has
# access to SignJwt for and which has been granted domain-wide
# delegation for --scopes
-x|--service-account-dwd)
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then
serviceaccountdwd=$2
shift 2
else
echo "Error: Argument for $1 is missing" >&2
exit 1
fi
;;
# Email address of the Workspace user which the --service-account should
# impersonate via domain-wide delegation.
-u|--user-dwd)
if [ -n "$2" ] && [ "${2:0:1}" != "-" ]; then
userdwd=$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 [ -z ${credentials_file+x} ]; then
echo "ERROR: you need to specify --credentials-file"
exit 1
fi
if [[ ! -r $credentials_file ]]; then
echo "ERROR: cannot read $credentials_file"
exit 1
fi
if [ -n "${DEBUG}" ]; then
curl_verbosity="-v"
else
curl_verbosity="-s"
fi
private_key="$(jq -r ".private_key" "${credentials_file}")"
if [ "${private_key}" == "null" ]; then
# if the credentials file doesn't contain a service account private key
# assume it's a client id/secret for end user credentials
client_id=$(jq -r ".installed.client_id" "${credentials_file}")
if [ "${client_id}" == "null" ]; then
echo "ERROR: couldn't read client_id or private key from ${credentials_file}."
echo "make sure --credentials-file specifies a client_secrets or oauth2 servcie account file."
exit 2
fi
client_secret=$(jq -r ".installed.client_secret" "${credentials_file}")
access_token=$(jq -r ".access_token" "${credentials_file}")
if [ "${access_token}" != "null" ]; then
# we have an access token and just need to check if it's expired.
expires_at=$(jq -r ".expires_at" "${credentials_file}")
time_now=$(date +%s)
if [ "${time_now}" -gt "${expires_at}" ]; then
# Use the refresh token to get a new access token
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#offline
refresh_token=$(jq -r ".refresh_token" "${credentials_file}")
refresh_result=$(curl $curl_verbosity "${token_endpoint}" \
-d "refresh_token=${refresh_token}" \
-d "client_id=${client_id}" \
-d "client_secret=${client_secret}" \
-d "grant_type=refresh_token")
access_token=$(jq -r ".access_token" <<< "${refresh_result}")
if [ "${access_token}" == "null" ]; then
echo "${refresh_result}"
exit 2
fi
expires_in=$(jq -r ".expires_in" <<< "${refresh_result}")
time_now=$(date +%s)
expires_at=$((time_now + expires_in - 60))
echo -e "new access token: $access_token\nexpires at: $expires_at"
euc_data=$(jq \
--arg access_token "${access_token}" \
--arg expires_at "${expires_at}" \
'. + {"access_token": $access_token, "expires_at": $expires_at | tonumber}' < "${credentials_file}")
echo -e "${euc_data}" > "${credentials_file}"
fi
else
# We need to authorize the user for the first time. Form the authorization URL
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#step-2-send-a-request-to-googles-oauth-20-server
iam_scope="https://www.googleapis.com/auth/iam email"
auth_url="https://accounts.google.com/o/oauth2/v2/auth?client_id=${client_id}&scope=${iam_scope}&response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob"
echo "Please go to:"
echo
echo "${auth_url}"
echo
echo "after accepting, enter the code you are given:"
read -r auth_code
# exchange authorization code for access and refresh tokens
# https://developers.google.com/identity/protocols/OAuth2InstalledApp#exchange-authorization-code
auth_result=$(curl $curl_verbosity "${token_endpoint}" \
-d "code=${auth_code}" \
-d "client_id=${client_id}" \
-d "client_secret=${client_secret}" \
-d "redirect_uri=urn:ietf:wg:oauth:2.0:oob" \
-d "grant_type=authorization_code")
refresh_token=$(jq -r ".refresh_token" <<< "${auth_result}")
if [ "${refresh_token}" == "null" ]; then
echo "${auth_result}"
exit 2
fi
access_token=$(jq -r ".access_token" <<< "${auth_result}")
expires_in=$(jq -r ".expires_in" <<< "${auth_result}")
time_now=$(date +%s)
expires_at=$((time_now + expires_in - 60))
euc_data=$(jq . < "${credentials_file}")
euc_data=$(jq \
--arg refresh_token "${refresh_token}" \
--arg access_token "${access_token}" \
--arg expires_at "${expires_at}" \
'. + {"refresh_token": $refresh_token, "access_token": $access_token, "expires_at": $expires_at | tonumber}' <<< "${euc_data}")
echo -e "${euc_data}" > "${credentials_file}"
fi
auth_bearer="${access_token}"
else
# Create a signed JWT for the local service account. This is not the same
# as the unsigned JWT we generate below which is sent to /SignJwt for the
# service account that actually has the domain-wide delegation.
# https://developers.google.com/identity/protocols/oauth2/service-account#jwt-auth
kid="$(jq -r ".private_key_id" "${credentials_file}")"
iss="$(jq -r ".client_email" "${credentials_file}")"
sub="${iss}"
iat="$(date +%s)"
# expire JWT just 30 seconds after issue (iat). We could go as high as
# an hour but a short exp decreases window for replay attacks.
exp="$((iat + 30))"
raw_header=$(jq --null-input \
--arg alg RS256 \
--arg typ JWT \
--arg kid "${kid}" \
'{"alg": $alg, "type": $typ, "kid": $kid}')
header=$(echo -n "${raw_header}" | openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
raw_payload=$(jq --null-input \
--arg iat "${iat}" \
--arg exp "${exp}" \
--arg iss "${iss}" \
--arg sub "${sub}" \
--arg aud "https://iamcredentials.googleapis.com/" \
'{"iat": $iat|tonumber, "exp": $exp|tonumber, "iss": $iss, "sub": $sub, "aud": $aud}')
payload=$( echo -n "${raw_payload}" | openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n' )
header_payload="${header}.${payload}"
signature=$(openssl dgst -sha256 -sign <(echo -ne "${private_key}") <(echo -n "${header_payload}") | openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n' )
auth_bearer="${header_payload}.${signature}"
fi
if [ -n "${DEBUG}" ]; then
echo
echo -e "Auth bearer to use for SignJwt is $auth_bearer"
echo
fi
# formulate the unsigned JWT to be used for DwD
# https://developers.google.com/identity/protocols/oauth2/service-account#authorizingrequests
iat="$(date +%s)"
# expire JWT just 30 seconds after issue (iat). We could go as high as
# an hour but a short exp decreases window for replay attacks.
exp="$((iat + 30))"
raw_payload=$(jq --null-input \
--arg iat "${iat}" \
--arg exp "${exp}" \
--arg scope "${scopes}" \
--arg iss "${serviceaccountdwd}" \
--arg sub "${userdwd}" \
--arg aud "${token_endpoint}" \
--compact-output \
'{"iat": $iat|tonumber, "exp": $exp|tonumber, "scope": $scope, "iss": $iss, "sub": $sub, "aud": $aud}')
raw_body=$(jq --null-input \
--arg payload "${raw_payload}" \
'{"payload": $payload}')
if [ -n "${DEBUG}" ]; then
echo
echo -e "Unsigned JWT to use for SignJwt is:\\n${raw_body}"
echo
fi
# Call IAM Credentials SignJwt
# https://cloud.google.com/vpc-service-controls/docs/supported-products#table_iam
# https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts/signJwt
signjwt_result=$(curl $curl_verbosity "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceaccountdwd}:signJwt" \
--header "Authorization: Bearer ${auth_bearer}" \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data "${raw_body}")
signed_jwt="$(echo "${signjwt_result}" | jq -r ".signedJwt")"
if [ "${signed_jwt}" == "null" ]; then
echo "${signjwt_result}"
exit 1
fi
if [ -n "${DEBUG}" ]; then
echo
echo -e "Signed JWT returned by SignJwt is:\\n${signed_jwt}"
echo
fi
# Now we can exchange the Signed DwD JWT for an access token
# https://developers.google.com/identity/protocols/oauth2/service-account#:~:text=making%20the%20access%20token%20request
token_result=$(curl $curl_verbosity "${token_endpoint}" \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
-d "assertion=${signed_jwt}")
access_token=$(echo "${token_result}" | jq -r ".access_token")
if [ "${access_token}" == "null" ]; then
echo "${token_result}"
exit 2
fi
if [ -n "${DEBUG}" ]; then
echo
echo -e "Token result:\\n${token_result}"
echo
fi
echo "${access_token}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment