Synchronize macOS account picture with Entra ID (formerly Azure AD)
Content
- Purpose
- Solution
- Sources / usefull resources
Purpose
One day, I wanted to synchronize Entra ID (formerly Azure AD) account picture with macOS devices account picture, so user will have the same behavior on Windows and macOS. I waited for Entra ID Platform SSO to comes out, but public preview shows that it does not synchronize user picture.

Solution
Here is a quick overview of what my solution looks like:
- Azure Function as midleware which will use MS Graph API to retrieve user picture. I choose to use it to prevent Graph API credentials exposure (see my previous post about this).
- A Shell script, run periodically by MDM, which perform HTTP calls to the Azure Function in order to retrieve user picture and set it as account picture.
Azure Function code
This Azure function uses the same base as the one here and require client certificate (certificate is validated in function code). You have to change line 42 to put the base64 string of your own CA (and sub-CA). Unfortunately you cannot use Intune MDM certificate as it does not allow all apps to use it (do not forget to allow all apps to use the certificate you deploy, I suggest to use a dedicated configuration and put purpose as OU like in my example).
The certificate is checked but you can improve security by adding the following checks:
- Check that device still exist in MDM
- Check that device is compliant
- Put owner UPN in certificate
To simplify requests, I choose to make a unique endpoint and return picture data directly when applicable.
Using namespace System.Net
# Input bindings are passed in via param block.
Param($Request, $TriggerMetadata)
$ApplicationID = "<APPID>"
$TenantID = "<TENANTID>"
$AccessSecret = "<SECRET>"
# Function to check certificate
Function Test-Certificate() {
$ClientCertificateBase64 = $Request.Headers."X-ARR-ClientCert"
# Check if certificate is provided
If (-not $ClientCertificateBase64) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = "{'status':'error','error':'Certificate not provided in request.'}"
})
exit
} Else{
# If provided, check trust
# Convert certificate to object
$ClientCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]([System.Convert]::FromBase64String($ClientCertificateBase64))
# Create custom chain object to trust only one authority
$Chain = [System.Security.Cryptography.X509Certificates.X509Chain]::Create()
## Do no check if certficate is revoked
$Chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck
## Use custom trust store
$Chain.ChainPolicy.TrustMode = [System.Security.Cryptography.X509Certificates.X509ChainTrustMode]::CustomRootTrust
## Add CAs to custom trust store
$ca="<BASE64CACERT>" # Without 'begin cert' and 'end cert'
$Chain.ChainPolicy.CustomTrustStore.Add([System.Security.Cryptography.X509Certificates.X509Certificate2]([System.Convert]::FromBase64String($ca))) | Out-Null
## build chain : return true if valid trust chain
If (-not $Chain.build($ClientCertificate)) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = "{'status':'error','error':'Certificate not valid ($($Chain.ChainStatus.StatusInformation)).'}"
})
} Else {
$ClientCertificate
}
}
}
# Function to authenticate and retrieve Graph API OAuth token
Function Get-GraphAPIToken() {
$Body = @{
Grant_Type = "client_credentials"
Scope = "https://graph.microsoft.com/.default"
client_Id = $ApplicationID
Client_Secret = $AccessSecret
}
Try {
$ConnectGraph = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token" `
-Method POST -Body $Body
} Catch {
Write-Host "Error for $Url"
Write-Host "Body:`n$Body"
Write-Host "StatusCode: $($_.Exception.Response.StatusCode.value__ )"
Write-Host "StatusDescription: $($_.Exception.Response.StatusDescription)"
}
If(-not $ConnectGraph.access_token){
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = "{'status':'error','error':'Internal server error while getting token.'}"
})
exit
}
return $ConnectGraph.access_token
}
# Function to get user id using Graph API
Function Get-UserID($UPN) {
$Bearer = Get-GraphAPIToken
# Get data
$url = "https://graph.microsoft.com/beta/users('$UPN')?`$select=displayName"
Try {
$UserData = (Invoke-RestMethod -Method 'GET' -Headers @{Authorization = "Bearer $($Bearer)"} -Uri $Url)
} Catch {
Write-Host "Error for $Url"
Write-Host "StatusCode: $($_.Exception.Response.StatusCode.value__ )"
Write-Host "StatusDescription: $($_.Exception.Response.StatusDescription)"
exit
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = "{'status':'error','error':'Internal server error while getting picture metadata.'}"
})
exit
}
$UserData.id
}
# Function to get profile picture metadata using Graph API
Function Get-PictureMetadata($id) {
$Bearer = Get-GraphAPIToken
# Get Metadata
$Url = "https://graph.microsoft.com/v1.0/users/$id/photo/"
Try {
$PictureMetadataRequest = (Invoke-RestMethod -Method 'GET' -Headers @{Authorization = "Bearer $($Bearer)"} -Uri $Url)
} Catch {
Write-Host "Error for $Url"
Write-Host "StatusCode: $($_.Exception.Response.StatusCode.value__ )"
Write-Host "StatusDescription: $($_.Exception.Response.StatusDescription)"
exit
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = "{'status':'error','error':'Internal server error while getting picture metadata.'}"
})
exit
}
$PictureMetadataRequest
}
# Function to get profile picture data using Graph API
Function Get-Picture($id) {
$Bearer = Get-GraphAPIToken
# Get file
$Url = "https://graph.microsoft.com/v1.0/users/$id/photo/`$value"
Try {
$PictureFileRequest = (Invoke-WebRequest -Method 'GET' -Headers @{Authorization = "Bearer $($Bearer)"} -Uri $Url)
} Catch {
Write-Host "Error for $Url"
Write-Host "StatusCode: $($_.Exception.Response.StatusCode.value__ )"
Write-Host "StatusDescription: $($_.Exception.Response.StatusDescription)"
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = "{'status':'error','error':'Internal server error while getting picture.'}"
})
exit
}
$PictureFileRequest.Content
}
# Test client certificate
#Test-Certificate
# Handle incomming requests
If($Request.Method -eq "GET") {
# Retrieve UPN
$UserUPN = $Request.Query.UPN
If(-not $UserUPN){
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::InternalServerError
Body = "{'status':'error','error':'UPN not provided.'}"
})
exit
}
################ Retrieve picture using Graph API ###########################
# Add Azure AD satus to note
$Id = Get-UserID($UserUPN)
$Metadata = Get-PictureMetadata($Id)
$File = Get-Picture($Id)
#######################################################################################
# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = [byte[]] $File
ContentType = $Metadata."@odata.mediaContentType"
Headers = @{
'Content-Disposition' = 'attachment; filename="profile.jpeg"'
}
})
exit
}
# Unknown method / request
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::MethodNotAllowed
})
Shell script
The client script relies on work from copyprogramming.com for setting user picture. I choose to iterate over users accounts to make all users have the proper picture, as I am using Entra ID Platform SSO to create accounts I filter the result to get only user containing domain (@ is ommited during creation but user have to login using UPN as well).
You may have to change line 107 to match what you choose as OU and eventually change the findCertAlias
function if you need more criteria.
Note: the string CURL_SSL_BACKEND=secure_transport
before the curl
command is used in order to allow curl
to use certificates in keychain instead of files.
Note 2: Users will need to reboot devices to apply change on login screen.
#!/bin/bash
APIURL="<AZURE-FUNCTION_URL>"
DOMAIN="<DOMAIN>"
CANAME="<CANAME>"
# Search certificate
function findCertAlias {
CERTISSUER=$1
CERTOU=$2
/usr/bin/security find-certificate -a -p > /tmp/certs.pem
while read line; do
if [[ "$line" == *"--BEGIN"* ]]; then
cert=$line
else
cert="$cert"$'\n'"$line"
if [[ "$line" == *"--END"* ]]; then
echo "$cert" > /tmp/checkcert.pem
subject=$(openssl x509 -subject -noout -in /tmp/checkcert.pem | sed 's/.*CN=\([^/]*\).*/\1/')
subjectOU=$(openssl x509 -subject -noout -in /tmp/checkcert.pem | sed 's/.*OU=\([^/]*\).*/\1/')
issuer=$(openssl x509 -issuer -noout -in /tmp/checkcert.pem | sed 's/.*CN=\([^/]*\).*/\1/')
purpose=$(openssl x509 -purpose -noout -in /tmp/checkcert.pem)
if [[ "$issuer" == $CERTISSUER ]] && [[ "$subjectOU" == $CERTOU ]] && (echo $purpose | grep -q "SSL client CA : No" ); then
echo $subject
break
fi
fi
fi
done < /tmp/certs.pem
rm -f /tmp/certs.pem
rm -f /tmp/checkcert.pem
}
# Set user picture for a specific user
function setUserPicture {
# Inspiraton: https://copyprogramming.com/howto/setting-account-picture-jpegphoto-with-dscl-in-terminal
USERNAME="$1"
USERPIC="$2"
PICTUREFOLDER="/Library/User Pictures/Pictures"
# Check if user exist
if /usr/bin/id -u "${USERNAME}" &>/dev/null; then
# Copy picture to public folder and set permissions to allow login windows display
mkdir -p "${PICTUREFOLDER}"
cp "${USERPIC}" "${PICTUREFOLDER}/${USERNAME}.jpeg"
chmod a+rx "${PICTUREFOLDER}/${USERNAME}.jpeg"
# Delete previous data
dscl . delete /Users/$USERNAME JPEGPhoto ||
dscl . delete /Users/$USERNAME Picture ||
# Set new profile picture
dscl . create /Users/$USERNAME Picture "${PICTUREFOLDER}/${USERNAME}.jpeg"
PICIMPORT="$(mktemp /tmp/${USERNAME}_dsimport.XXXXXX)"
MAPPINGS='0x0A 0x5C 0x3A 0x2C'
ATTRS='dsRecTypeStandard:Users 2 dsAttrTypeStandard:RecordName externalbinary:dsAttrTypeStandard:JPEGPhoto'
printf "%s %s \n%s:%s" "${MAPPINGS}" "${ATTRS}" "${USERNAME}" "${PICTUREFOLDER}/${USERNAME}.jpeg" > "${PICIMPORT}"
/usr/bin/dsimport "${PICIMPORT}" /Local/Default M &&
rm "${PICIMPORT}"
fi
}
# Download user picture
function downloadUserPicture {
CERTALIAS=$1
UPN=$2
response=$(CURL_SSL_BACKEND=secure_transport curl -X GET "$APIURL&UPN=$UPN" --cert "$CERTALIAS" -sS --write-out '|%{http_code}\n' -o /tmp/$UPN.jpeg)
RC=$?
if [[ ${RC} == 0 ]]; then
IFS='|'
read -a response <<< "$response"
if [[ ${response[1]} != 200 ]]
then
echo "Error for ${APIURL}&UPN=${UPN}" >&2
echo "HTTP Code: ${response[1]}" >&2
echo "Body: $(cat /tmp/${UPN}.jpeg)" >&2
rm -f /tmp/$UPN.jpeg
return 1
fi
echo "/tmp/$UPN.jpeg"
return 0
else
echo "Error for ${APIURL}&UPN=${UPN}" >&2
echo "Exit Code: ${RC}" >&2
echo "Output: $response" >&2
rm -f /tmp/$UPN.jpeg
return 1
fi
}
# Get alias of Intune provided certificate
certAlias=$(findCertAlias "$DOMAIN" "AzureFunction")
for user in $(dscl . -list /Users | grep "$DOMAIN"); do
echo $user
# @ must be added
picture="$(downloadUserPicture $certAlias ${user//"$DOMAIN"/"@$DOMAIN"})"
RC=$?
if [[ ${RC} == 0 ]]; then
setUserPicture $user $picture
fi
done