Last active
November 18, 2022 21:01
-
-
Save jay0lee/0b7959b2b17a869cac117af9025af7f4 to your computer and use it in GitHub Desktop.
This file contains 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 | |
### | |
### 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