Receiving phone calls on our regular soft phone while in a Teams meeting is quite annoying. It would be quite useful to set the soft phone presence according to the Teams presence.
In my last blog entry I wrote about how to do this the other way around.
Microsoft moved the notification on Graph changes via webhook from beta to 1.0 some time ago. Yet there are a few obstacles to overcome:
- the need for a webhook listening service that handles validation
- since presence state notification contains rich information data needs to be encryted/decrypted
- for now Presence.Read.All only supports delegated permissions, which requires some sort of user interaction when first acquiring a token
First I implemented a very rudimentary HTTP server that handles webhook validation and is able to decrypt rich data payloads (as found in presence update notifications).
To get started easily one can use Ngrok to setup an HTTPS->HTTP tunnel to the local machine.
On the local machine socat does the socket handling, forking the made-up "web server" for each connection.
#!/bin/bash
#
# ngrok http 1234
# socat -v -d -d tcp-listen:1234,reuseaddr,fork exec:"./server.sh",fdin=3,fdout=4
#
# FD 3 => http client request
# FD 4 => http server response
#
shopt -s nocasematch
# this file is created by teams_presence_notification.sh
key_file="/tmp/azure.key"
# this is the URL path used for the webhook
webhook_path="/teams-presence-notification"
# URL decoding
url_decode() {
local LANG=C i
i="${*//+/ }"
echo -e "${i//%/\\x}"
}
# QUERY_STRING parsing (arg1)
# - returns global "res_${key}=${val}" variable(s)
parse_query_string() {
local saveIFS=$IFS parm i
IFS='=&'
parm=($1)
IFS=$saveIFS
for ((i=0; i<${#parm[@]}; i+=2)) ; do
eval res_${parm[i]}=${parm[i+1]}
done
}
read -u 3 -r request
# check if this is a valid URL path
if [[ ! "$request" =~ "${webhook_path}" ]] ; then exit ; fi
parse_query_string $(echo "$request" | sed -E 's/(POST|GET).*\?|\s+HTTP\/.*$//g')
# handle webhook validation call
if [ -n "$res_validationToken" ] ; then
res_validationToken=$(url_decode "$res_validationToken")
printf 'HTTP/1.0 200 OK\nContent-Type: text/plain\n\n%s' "$res_validationToken" 1>&4
exit
fi
# read remaining headers to find out if there is a request body(marked by Content-Length header)
length=1;
while read -u3 request ; do
case ${request%%[[:space:]]} in
"")
# an empty line denotes the end of headers
break
;;
Content-Length*)
length=${request/Content-Length:/}
length=${length##[[:space:]]}
length=${length%%[[:space:]]}
;;
esac
done
# read body if any
read -u 3 -d '' -n $length body
# answer "200 OK" to everything
printf 'HTTP/1.0 200 OK\n\n' 1>&4
# decrypt body if it contains encrypted content
if [[ "$body" =~ "encryptedContent" ]] ; then
key=$(echo $body | jq -r '.value[0].encryptedContent.dataKey' | base64 -d | \
openssl rsautl -decrypt -inkey "${key_file}" -oaep | hexdump -e '16/1 "%02x"')
iv=$(echo $key | head --bytes 32)
echo $body | jq -r '.value[0].encryptedContent.data' | base64 -d | \
openssl enc -d -aes-256-cbc -K $key -iv $iv
else
echo $body
fi
Now that the webhook is listening, the actual subscription to presence updates can be performed:
#!/bin/bash
tenant='your tenant ID'
client_id='your client ID'
webhook="https://a1b2-34-567-890-123.ngrok-free.app/teams-presence-notification"
# offline_access Scope required in order to receive a Refresh-Tokens
scope='https://graph.microsoft.com/User.ReadBasic.All https://graph.microsoft.com/Presence.Read.All offline_access'
# Presence.Read.All currently only allows delegated permission !!!
# therefore a user-identity has to login in order to retrieve access tokens.
#
# device-code grant is used here as we're unable to receive a redirectURI call
# this requires the App to allow "public client flows"
grant_type='urn:ietf:params:oauth:grant-type:device_code'
token_endpoint="https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token?"
device_endpoint="https://login.microsoftonline.com/${tenant}/oauth2/v2.0/devicecode?"
access_token_file="/tmp/azure.access_token"
refresh_token_file="/tmp/azure.refresh_token"
response_file="/tmp/azure.response"
cert="/tmp/azure.cert"
key="/tmp/azure.key"
user_file="/tmp/azure.users"
if [ "$1" = '-h' -o "$1" = 'help' -o "$1" = '-help' -o "$1" = '--help' ] ; then
echo "Usage: $0 [ <user1> [ .. <userX>] | delete | reauthorize [<subscription-id>] ]"
exit 1
elif [ "$1" = "reauthorize" ] ; then
user_list=$1
subscription=$2
else
user_list=$@
fi
if [ ! -f "${user_file}" ] ; then
touch "${user_file}"
fi
trap "exit 1" TERM
export MYPID=$$
# Login Prozess - returns refresh_token and access_token (and stores them in files)
token_login() {
res=$(curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' "${device_endpoint}" \
--data-urlencode "client_id=${client_id}" \
--data-urlencode "scope=${scope}")
# Request user to perform Device Login
echo "${res}" | jq -r '.message'
device_code=$(echo "${res}" | jq -r '.device_code')
expires_in=$(echo "${res}" | jq -r '.expires_in')
interval=$(echo "${res}" | jq -r '.interval')
count=0
auth=""
# Polling token_endpoint until auth-tokens are received (or timeout)
until [ -n "${auth}" ] ; do
count=$((count+interval))
if [ ${count} -ge ${expires_in} ] ; then
echo "Error: Authentication timeout"
kill -TERM $MYPID
fi
sleep $interval
# try to retrieve the tokens
res=$(curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' "${token_endpoint}" \
--data-urlencode "grant_type=${grant_type}" \
--data-urlencode "client_id=${client_id}" \
--data-urlencode "device_code=${device_code}")
# handle error
error=$(echo "${res}" | jq -r '.error|values')
if [ -n "${error}" ] ; then
if [ "${error}" != "authorization_pending" ] ; then
echo "Error: $error"
echo "${res}" | jq -r '.error_description'
kill -TERM $MYPID
fi
else
# handle response otherwise
auth=$res
fi
done
access_token=$(echo "${auth}" | jq -r '.access_token|values')
refresh_token=$(echo "${auth}" | jq -r '.refresh_token|values')
if [ -z "${access_token}" ] ; then
echo "Error: something went wrong"
echo "${auth}"
kill -s TERM $MYPID
fi
# write token to file
echo "${access_token}" > "${access_token_file}"
echo "${refresh_token}" > "${refresh_token_file}"
}
# get new access_token from refresh_token
token_refresh() {
# if Client_Secret is set...
if [ -n "${client_secret}" ] ; then
res=$(curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' "${token_endpoint}" \
--data "client_id=${client_id}" \
--data-urlencode "client_secret=${client_secret}" \
--data "scope=${scope}" \
--data "grant_type=refresh_token" \
--data-urlencode "refresh_token=${refresh_token}")
# ...also works without Client_Secret
else
res=$(curl -s -X POST -H 'Content-Type: application/x-www-form-urlencoded' "${token_endpoint}" \
--data "client_id=${client_id}" \
--data "scope=${scope}" \
--data "grant_type=refresh_token" \
--data-urlencode "refresh_token=${refresh_token}")
fi
# write token to file
echo "${res}" | jq -r '.access_token' > "${access_token_file}"
echo "${res}" | jq -r '.refresh_token' > "${refresh_token_file}"
refresh_token=$(cat "${refresh_token_file}")
access_token=$(cat "${access_token_file}")
}
# check if access_token is still valid
check_access_token() {
if [ -f "${access_token_file}" -a -f "${refresh_token_file}" ] ; then
refresh_token=$(cat "${refresh_token_file}")
access_token=$(cat "${access_token_file}")
# check the most basic Graph endpoint if it returns data
error=$(curl -s -X GET -H "Authorization: Bearer ${access_token}" -w "%{http_code}" \
-o "${response_file}" "https://graph.microsoft.com/v1.0/me")
if [ "$error" = "401" ] ; then
token_refresh
fi
else
token_login
fi
}
# get User-Id from Azure-UPN
get_user() {
# check cached id first
id=$(cat "${user_file}" | grep "${user}" | cut -d" " -f2)
if [ -z "$id" ] ; then
id=$(curl -s -X GET -H "Authorization: Bearer ${access_token}" \
"https://graph.microsoft.com/v1.0/users/${user}" | jq -r '.id')
echo "${user} ${id}" >> "${user_file}"
fi
}
# get active Subscription-Id (there seems only one subscription per session possible)
get_active_notification() {
res=$(curl -s -X GET -H "Authorization: Bearer ${access_token}" -H "Content-Type: application/json" \
"https://graph.microsoft.com/v1.0/subscriptions" | \
jq -r '.value[] | "\(.id) \(.expirationDateTime)"')
id=$(echo $res | cut -d' ' -f1)
}
# delete active Subscription
delete_active_notification() {
get_active_notification
if [ -n "$id" ] ; then
curl -s -X DELETE -H "Authorization: Bearer ${access_token}" -H "Content-Type: application/json" \
"https://graph.microsoft.com/v1.0/subscriptions/${id}"
fi
}
# reauthorizes a subscription
reauthorize_notification() {
if [ -n "$id" ] ; then
expiration=$(date -d '+1 hour' -u +'%FT%T.%7NZ')
subscription='{"expirationDateTime": "'${expiration}'"}'
res=$(curl -s -X PATCH -H "Authorization: Bearer ${access_token}" -H "Content-Type: application/json" \
"https://graph.microsoft.com/v1.0/subscriptions/${id}" -d "${subscription}" | \
jq -r '"\(.id) \(.expirationDateTime)"' )
id=$(echo $res | cut -d' ' -f1)
fi
}
# creates new subscription
setup_notification() {
id_list=""
# user_list can be separated by comma or space
for user in ${user_list//,/ } ; do
id=""
get_user
if [ -n "$id" ] ; then
if [ -n "$id_list" ] ; then
id_list="${id_list},'${id}'"
else
id_list="'${id}'"
fi
fi
done
# create new public/private key-pair for payload encryption if it doesn't exist
if [ ! -f "$cert" -o ! -f "$key" ] ; then
openssl req -x509 -newkey rsa:2048 -keyout "$key" -out "$cert" \
-sha256 -days 365 -nodes -subj '/CN=localhost'
fi
cert=$(cat "$cert" | grep -vE '^-----' | sed -ze 's/\n//g')
# for presence max. duration is 60min.
expiration=$(date -d '+1 hour' -u +'%FT%T.%7NZ')
subscription='{ "changeType": "updated",
"notificationUrl": "'${webhook}'",
"lifecycleNotificationUrl": "'${webhook}'",
"resource": "/communications/presences?$filter=id in ('${id_list}')",
"includeResourceData": true,
"encryptionCertificate": "'${cert}'",
"encryptionCertificateId": "20230419",
"expirationDateTime": "'${expiration}'",
"clientState": "AlexTest" }'
res=$(curl -s -X POST -H "Authorization: Bearer ${access_token}" -H "Content-Type: application/json" \
"https://graph.microsoft.com/v1.0/subscriptions" -d "${subscription}" | \
jq -r '"\(.id) \(.expirationDateTime)"')
id=$(echo $res | cut -d' ' -f1)
}
###########
# Main
###########
check_access_token
if [ "$user_list" = "delete" ] ; then
delete_active_notification
elif [ "$user_list" = "reauthorize" ] ; then
if [ -z "${subscription}" ] ; then
get_active_notification
else
id=$subscription
fi
reauthorize_notification
echo $res
elif [ -n "$user_list" ] ; then
setup_notification
echo $res
else
get_active_notification
echo $res
fi
The script also subscribes to lifecycle events which it can handle with the reauthorize parameter.
In a follow-up article I am going to demonstrate how we pieced together the two Teams presence posts in a solution for our PBX.
Keine Kommentare:
Kommentar veröffentlichen