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