Microsoft/Office 365 PowerShell in Azure Functions – AzureAD Module and Graph

By | May 10, 2021

I’m a big fan of running powershell in function apps. Sorry, no that’s wrong – I’m a big fan of NOT running powershell on “reporting servers”, “management boxes”, “that vm that does the 365 stuff” or anywhere where there is even the slightest chance I’m going to find myself fixing a problem on a windows box like olden times might crop up. I’ve already posted about Setting up function apps to run powershell, Creating AAD app registrations for service principals and using the 64 bit exchange module to manage exchange in a function app. This post is to go through some of my “template function”. This is a function app and template function I built which can use as many modules as possible and make graph calls really easily, when I want to create a new function I just copy it. This post goes through the next most important parts after exchange – AzureAD and Graph

Available Modules

My main prerequisite for this app is that it all must run under cert-based service principals. No user identities, no client secrets. That has some fairly harsh effects. The main one being – no MSOL Service module. The majority of what you can do in that module is replicated between the AzureAD and Graph but its a huge miss at times. It would of course be pretty trivial to create a user identity with some nice role memberships and a long random password but that’s precisely what I’m trying to get rid of. We also dont have the skype for business online module – which doesnt seem like an issue – but it actually controls a lot of the UC stuff for Teams and critically the teams upgrade settings for putting users in Islands/Teams Only mode etc) That one is a bummer.

We do have AzureAD, Exchange, Teams, Az (built in), Sharepoint (but online with PnP module) so there’s a tonne of stuff we can do.

This post looks at adding the AzureAD module and Microsoft Graph to the toolset.

I’ve already gone through how to import the Exchange module and get that working. I included a demo code on that piece that showed it connecting and grabbing a single user’s account.

If you followed that post then this will look familiar, except I have moved the Exchange connection into a callable function. Because this is my template app its going to contain the capability to do all of my connections but only call them as that particular function needs.

# Input bindings are passed in via param block.
param($Timer)

$64bitPowerShellPath = Get-ChildItem -Path $Env:Windir\WinSxS -Filter PowerShell.exe -Recurse -ErrorAction SilentlyContinue | Where-Object {$_.FullName -match "amd64"}
$env:64bitPowerShellPath=$64bitPowerShellPath.VersionInfo.FileName
$script = {
    #Set variables
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantdomain="starfleet.onmicrosoft.com"

    #connection functions
    Function ConnectExchange {
        Import-Module "D:\home\site\wwwroot\ExchangeOnline\ExchangeOnlineManagement.psd1"
        Connect-ExchangeOnline -CertificateThumbprint $CertThumbprint -AppId $AppID -Organization $tenantdomain -Showbanner:$false
    }
    #script
    ConnectExchange
    get-mailbox -Identity kirkjt@enterprise.starfleet.com
}

$ScriptResult = (&$env:64bitPowerShellPath -WindowStyle Hidden -NonInteractive -Command $Script)
$ScriptResult

AzureAD

The first module to add is AzureAD as between that and Exchange we have a tremendous coverage of capabilities.

The method is exactly as per the Exchange guide

  1. Export the AzureAD module from a machine
  2. Use the Function App Development tools Kudu Advanced tools console to upload the entire module folder content to a new folder D:\home\site\wwwroot\AzureAD in the Function App
  3. You can now import and use the module.

I simply added a function to my template code called ConnectAzureAD

# Input bindings are passed in via param block.
param($Timer)

$64bitPowerShellPath = Get-ChildItem -Path $Env:Windir\WinSxS -Filter PowerShell.exe -Recurse -ErrorAction SilentlyContinue | Where-Object {$_.FullName -match "amd64"}
$env:64bitPowerShellPath=$64bitPowerShellPath.VersionInfo.FileName
$script = {
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantdomain="starfleet.onmicrosoft.com"
    $tenantid = "aad_tenantid"

    #connection functions
    Function ConnectExchange {
        Import-Module "D:\home\site\wwwroot\ExchangeOnline\ExchangeOnlineManagement.psd1"
        Connect-ExchangeOnline -CertificateThumbprint $CertThumbprint -AppId $AppID -Organization $tenantdomain -Showbanner:$false
    }
    Function ConnectAzureAD {
        Import-Module "D:\home\site\wwwroot\AzureAD\AzureAD.psd1"
        Connect-AzureAD -CertificateThumbprint $CertThumbprint -ApplicationId $AppID -TenantId $tenantid
    }
    #script
   # ConnectExchange
   # get-mailbox -Identity kirkjt@enterprise.starfleet.com

    ConnectAzureAD
    get-AzureADUser -ObjectId kirkjt@enterprise.starfleet.com
}



$ScriptResult = (&$env:64bitPowerShellPath -WindowStyle Hidden -NonInteractive -Command $Script)
$ScriptResult

You’ll see I’ve commented out the test call of ConnectExchange and added a call to the new function ConnectAzureAD to grab a user account just to test its all working. (Have you noticed that Get-azureaduser will accept a UPN in objectid but nothing else does?)

Also note that the Azure AD module uses tenant id instead of organization domain so Ive added that to the variables at the top of the script section.

Regarding API permissions if you have created your service principal account as per my previous post it will have Microsoft Graph User.Read delegated permissions so will already work to grab and read a user account.

If you try to update an attribute for example by adding

get-AzureADUser -ObjectId kirkjt@enterprise.starfleet.com
 | Set-AzureADUser -StreetAddress "Qo'Nos"

in the script block you’ll get a permission error. You’ll need to go into Azure Active Directory and your App registration, open the API permissions and add:

You’ll get all sorts of warnings that this API is being deprecated and you should use the Microsoft Graph API but its needed for the module. The alternative is to add the service principal into another Role (as we added it to Exchange Administrator), but I prefer to keep the permissions to a minimum. One thing to note is that no set of API permissions will allow you to delete directory objects, for that your are going to need to go through the role method.

Graph

Now we have Exchange and Azure AD there’s a tonne of tasks that can be fully automated. I for example have scripts that dump the users of a dynamic AAD security group into a static DL because a lot of the compliance “stuff” will happily work against a DL but not a security group. Thanks Microsoft.

The other main requirement is likely reporting. I HATE the fact almost every 365 infrastructure ends up emailing powershell generated reports and data all over the place (that almost without exception hit an outlook rule somewhere to go into a “special” area of the recipients mailbox). I like ex-filtrating data and slapping it into PowerBI instead and have many posts on doing this with graph and flow. Handily Function Apps can do both -I’ll be doing something on Function Apps to Flow to CDS to Power BI shortly. It almost always “needs to be done” however so lets look at email first because the way I do it uses Graph, and once you have that working you can use all of Graph which opens up a whole world of pure imagination…

Our first issue is authentication. Basically if you want to authenticate to Graph with a nice old client secret its trivial. We dont though,. We want to use a certificate. The method here is to use another module – the MS Authentication Library – to generate an access token then use it to authenticate with graph.

Much of this borrows or steals from the concepts used at https://blog.darrenjrobinson.com/microsoft-graph-using-msal-with-powershell-and-certificate-authentication/ so that’s worth a good look especially if you have never played with MSAL before as the old method using ADAL is seemingly for the chop

To get the module, exactly as for the others install it locally with

Install-Module -name MSAL.PS -Force -AcceptLicense

then get the location with

get-installedmodule | select name, installedlocation

Jump into Azure Portal | Function App | <Your Function App> | Developement Tools | Advanced Tools and create a new folder exactly as before but called MSAL, then dump the entire local module install into here

Now in our function app we can import the module the use the cmdlet “Get-MSALToken” to generate a token:

Import-Module "D:\home\site\wwwroot\MSAL\MSAL.PS.psd1"
$cert = Get-Item "Cert:\CurrentUser\My\$($CertThumbprint)"
$MSALToken = Get-MsalToken -ClientId $appID -TenantId $tenantID -ClientCertificate $cert

The cmdlet doesnt accept the thumbprint then grabbing the cert from the store itself so you’ll see theres an extra strep where we do that. THis will generate us an access token in $MSALtoken, now we want to use it to log in to graph and do something:

(Invoke-RestMethod -Headers @{Authorization = "Bearer $($MSALToken.AccessToken)" } -Uri "https://graph.microsoft.com/v1.0/users" -Method Get).value

This queries the graph URI v1.0/users using our generated token and returns a list of users then extracts each value. We can dump this into a variable to play with later. Be aware by stripping the value we are stripping the pagination details so end up with only the first 100 results. I will get around to writing something about de-paginating results in functions later, I promise.

Okay so now we can generate a token and authorise into graph which is awesome. There is a bit of a proviso when we add this functionality to the template app, here’s the code:

# Input bindings are passed in via param block.
param($Timer)

$64bitPowerShellPath = Get-ChildItem -Path $Env:Windir\WinSxS -Filter PowerShell.exe -Recurse -ErrorAction SilentlyContinue | Where-Object {$_.FullName -match "amd64"}
$env:64bitPowerShellPath=$64bitPowerShellPath.VersionInfo.FileName

Function ConnectGraph($GraphURI) {
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantid = "aad_tenantid"

    Import-Module "D:\home\site\wwwroot\MSAL\MSAL.PS.psd1"
    $cert = Get-Item "Cert:\LocalMachine\My\$($CertThumbprint)"
    $MSALToken = Get-MsalToken -ClientId $appID -TenantId $tenantID -ClientCertificate $cert
    (Invoke-RestMethod -Headers @{Authorization = "Bearer $($MSALToken.AccessToken)" } -Uri $GraphURI -Method Get).value
}
$script = {
    #Set variables
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantdomain="starfleet.onmicrosoft.com"
    $tenantid = "aad_tenantid"


    #connection functions
    Function ConnectExchange {
        Import-Module "D:\home\site\wwwroot\ExchangeOnline\ExchangeOnlineManagement.psd1"
        Connect-ExchangeOnline -CertificateThumbprint $CertThumbprint -AppId $AppID -Organization $tenantdomain -Showbanner:$false
    }
    Function ConnectAzureAD {
        Import-Module "D:\home\site\wwwroot\AzureAD\AzureAD.psd1"
        Connect-AzureAD -CertificateThumbprint $CertThumbprint -ApplicationId $AppID -TenantId $tenantid
    }

    #script
   # ConnectExchange
   # get-mailbox -Identity kirkjt@enterprise.starfleet.com

    #ConnectAzureAD
    #get-AzureADUser -ObjectId kirkjt@enterprise.starfleet.com
    

}


$GraphResult = ConnectGraph -graphuri "https://graph.microsoft.com/v1.0/users"
$GraphResult
$ScriptResult = (&$env:64bitPowerShellPath -WindowStyle Hidden -NonInteractive -Command $Script)
#$ScriptResult# Input bindings are passed in via param block.

So I have added in a new function ConnectGraph with a parameter of the URI we want to run to pass. I’ve commented out our test stuff from the other modules too and added ina test to call https://graph.microsoft.com/v1.0/users and stick the result in $GraphResult and then dump that variable out to the console.

There is a very important thing to notice here – the ConnectGraph funtion is NOT in the script block that runs under the 64 bit powershell module. If you are interested as to why then dont skip the next paragraph.

We are calling the 64 bit powershell executable direct off the host system because the modules we want to use – AzureAD and ExchangeOnline for example – are 64 bit only. The problem is that the version of powershell.exe is not under our direct control and at powershell 5 (ish) they added a cmdlet that dumps a psd1 (powershell module file) into a hashtable called “Import-PowerShellDataFile”. The MSAL.PS module uses this cmdlet right at the start and if it isn’t there (it isn’t) the module import fails. The MSAL.PS module however is 32 bit so we can run it against the function app’s native Powershell 7 x86 environment which we do control no problem. We could use this to generate a token in powershell 7 x86 then pass that token into the 64 bit powershell AMD64 to do the graph call but to be honest that seemed a massive hassle for very little benefit.

The upshot of this is that we need to things that use the 64 bit modules in a block, store the output in a variable, then do the things with Graph using that variable. Obviously because we generate the MSAL token in the “native” Powershell 7 x86 environment we cant do something in Graph then feed the results into the standard powershell modules running in the Powershell AMD64 environment. *We could but then we need to break the whole script out into a function and to be honest I have never even once come across a reason to do it). Its not a big deal its just something to remember.

So we have Graph working from our powershell file, which is great, I’m sure you can think of a million hugely useful things you can do with this (dont forget you’ll need better API permissions than our current Directory.Read for fun stuff!) but the first thing we wanted to do is be able to send out reports by email. It would be relatively straightforward to assemble the sender, recipient, subject, etc into a big URI string each time I want to email something but I use another function, here’s my full code, its getting lengthy now:

# Input bindings are passed in via param block.
param($Timer)

$64bitPowerShellPath = Get-ChildItem -Path $Env:Windir\WinSxS -Filter PowerShell.exe -Recurse -ErrorAction SilentlyContinue | Where-Object {$_.FullName -match "amd64"}
$env:64bitPowerShellPath=$64bitPowerShellPath.VersionInfo.FileName

Function ConnectGraph($GraphURI, $Body, $Method) {
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantid = "aad_tenantid"

    Import-Module "D:\home\site\wwwroot\MSAL\MSAL.PS.psd1"
    $cert = Get-Item "Cert:\CurrentUser\My\$($CertThumbprint)"
    $MSALToken = Get-MsalToken -ClientId $appID -TenantId $tenantID -ClientCertificate $cert
    If (!$Body -and $Method -eq "Get"){
        (Invoke-RestMethod -Headers @{Authorization = "Bearer $($MSALToken.AccessToken)" } -Uri $GraphURI -Method Get).value
    }
    ElseIf ($Body -and $Method -eq "Post") {

        (Invoke-RestMethod -Headers @{Authorization = "Bearer $($MSALToken.AccessToken)";'Content-type'="application/json" } -Uri $GraphURI -Method Post -Body $Body).value

    }
}

Function MailOut([string]$MailSubject, [string]$MailContent, [string]$MailSender, [string]$MailRecipient, $AttachmentString, [string]$AttachmentName) {
    $AttachmentBase64 = [Convert]::ToBase64String([System.Text.Encoding]::Utf8.GetBytes($attachmentstring))
    If($AttachmentString) {
        $OutMail = '{
            "message": {
                "subject": "'+$MailSubject+'",
                "body": {
                    "contentType": "Html",
                    "content": "'+$MailContent+'"
                },
            "toRecipients": [
                {
                    "emailAddress": {
                    "address": "'+$MailRecipient+'"
                    }
                }
            ],
            "attachments": [
                {
                    "@odata.type": "#microsoft.graph.fileAttachment",
                    "name": "'+$AttachmentName+'",
                    "contentType": "string",
                    "contentBytes": "'+$AttachmentBase64+'"
                }
                ]
            }
            }'
        }
    ElseIF (!$AttachmentString) {
        $OutMail = '{
            "message": {
                "subject": "'+$MailSubject+'",
                "body": {
                    "contentType": "Html",
                    "content": "'+$MailContent+'"
                },
            "toRecipients": [
                {
                    "emailAddress": {
                    "address": "'+$MailRecipient+'"
                    }
                }
            ]
            }
        }'
    }


    ConnectGraph -graphuri "https://graph.microsoft.com/v1.0/users/$MailSender/sendmail" -method "Post" -Body $OutMail

}
$script = {
    #Set variables
    $AppID="AppID"
    $CertThumbprint="certificate_thumbprint"
    $tenantdomain="starfleet.onmicrosoft.com"
    $tenantid = "aad_tenantid"


    #connection functions
    Function ConnectExchange {
        Import-Module "D:\home\site\wwwroot\ExchangeOnline\ExchangeOnlineManagement.psd1"
        Connect-ExchangeOnline -CertificateThumbprint $CertThumbprint -AppId $AppID -Organization $tenantdomain -Showbanner:$false
    }
    Function ConnectAzureAD {
        Import-Module "D:\home\site\wwwroot\AzureAD\AzureAD.psd1"
        Connect-AzureAD -CertificateThumbprint $CertThumbprint -ApplicationId $AppID -TenantId $tenantid
    }



    #ConnectAzureAD
    #get-AzureADUser -objectid 

    ConnectExchange
    get-mailbox -Identity kirkjt@enterprise.starfleet.com 

}

$ScriptResult = (&$env:64bitPowerShellPath -WindowStyle Hidden -NonInteractive -Command $Script)
$scriptresult | select DisplayName, WindowsEmailaddress| Export-Csv -Path ".\results.csv"


$Results = Get-Content ".\results.csv" -Encoding UTF8 -Raw
MailOut -MailSubject "Users from Graph" -MailContent "Graph Export Attached" -MailSender parist@deltaflyer.starfleet.com -MailRecipient siskob@demigods.com -AttachmentString $Results -AttachmentName "graphresult2.csv"

Okay so firstly I’ve made some changes to the ConnectGraph function – namely to check if the call to the function included a $Body variable and if it asked to do a http Post instead of Get. The Get invocation is still the same, the Post adds in a body and changes the method.

Note the Headers have also been changed, as well as our hard-won token were specifying the Content type.

Next we have the MailOut function. We need to take our attachment conetnt as a string and covert it to Base64 but other than that there’s nothing special in here it all just standard powershell to assemble a message variable in JSON. The schema required is very well documented at https://docs.microsoft.com/en-us/graph/api/user-sendmail but its pretty straight-forward. There’s an If/ElseIf to see if you are sending an attachment as we cant specify one then leave it blank. I have also only specified a single sender but obviously this can be a group or DL address – its easier to use these than have to change your code every time someone leaves or joins a team.

In the form above the script then will first run the $script which open Powershell AMD64, loads the exchange module and grabs the details of a single user, returning that as a variable $ScriptResult. We then do a quick select on that to grab the attributes we want and save it as a csv called results.csv.

Saving the file has two purposes. Firstly we can grab it through the debug tools to have a look if something is going wrong to try to work out what. Secondly and more importantly it allows us to then load a $results variable specifying the encoding and raw parameters before passing it to our new MailOut function along with sender, recipients, mail content and atatchment name. This then assembles the message and calls ConnectGraph which sends the mail. You are thinking “well you could have just posted $scriptresults right into the MailOut function. Kinda. You have to convertto-csv and when you do the ToBase64String conversion it drops all of the line feeds. There are probably other ways around it but this one works.

For this to work you will need to add the API permissions:

So the specified recipient will get a nice email with the results attached as a .csv. Awesome.

Loading

Leave a Reply

Your email address will not be published. Required fields are marked *