Nosari20
Synchronize macOS account picture with Entra ID (formerly Azure AD)

Synchronize macOS account picture with Entra ID (formerly Azure AD)

Content

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.

User accounts list

Solution

Here is a quick overview of what my solution looks like:

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:

To simplify requests, I choose to make a unique endpoint and return picture data directly when applicable.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
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/v1.0/users/$UPN"
    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.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#!/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 

Sources / usefull resources