Mittwoch, 26. April 2023

Teams Presence Notification via CLI

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