Inside Exchange Online PowerShell

Auf dieser Seite sammle ich ein paar PowerShell-Aufrufe und Erkenntnisse, die ich immer mal wieder brauchen kenn. Sie Analyse wurde mit der Exchange Online PowerShell 3.3.0 Anfang Dezember 2023 durchgeführt. Neuere Versionen können sich anders verhalten.

Auslöser

Eigentlich wollte ich nur besser verstehen, wie so ein Powershell-Modul arbeitet und hinter die Kulissen schauen. Wir wissen alle, wie so eine Exchange Online PowerShell Sessions ablaufen kann:

# PowerShell starten

# Mit Exchange Online verbinden
PS C:> Connect-ExchangeOnline

# und dann die gewünschten Aktionen ausführen
PS C:> Get-Mailbox

PS C:> (Get-OrganizationConfig).identity 
msxfaqdev.onmicrosoft.com 

# An Ende wieder trennen
PS C:> Disconnect-ExchangeOnline

Damit all die Befehle zwischen dem "Connect" und dem "Disconnect" funktionieren, muss die PowerShell irgendwo die Authentifizierung speichern und mit einem einfachen "Get-Variable" habe ich keine Session-Variable o.ä. gefunden. Daher muss die PowerShell das irgendwie anders machen.

Tipp: Allein durch das Lesen und Analysieren von Microsoft PowerShell-Modulen können Sie viel über "richtiges Programmieren" lernen und sich einige Tricks und Kniffe abschauen.

Analyse Disconnect-ExchangeOnline

Ich habe daher eine Verbindung zu meinem Developer Tenant aufgebaut und dann den Code hinter Disconnect-ExchangeOnline angeschaut. Das geht recht einfach mit:

(get-command Disconnect-ExchangeOnline).scriptblock

Die PowerShell liefert dann den kompletten Code, der bei einem Disconnect-ExchangeOnline ausgeführt wird. Ich musste gar nicht den ganzen Code verstehen aber die Stelle zum "Löschen" der Authentifizierungsdaten ist einfach auszumachen. Hier ein Auszug:

# Import the module once more to ensure that Test-ActiveToken is present
$ExoPowershellModule = "Microsoft.Exchange.Management.ExoPowershellGalleryModule.dll";
$ModulePath = [System.IO.Path]::Combine($PSScriptRoot, $ExoPowershellModule);
Import-Module $ModulePath -Cmdlet Clear-ActiveToken;

$existingPSSession = Get-PSSession | Where-Object {$_.ConfigurationName -like "Microsoft.Exchange" -and $_.Name -like "ExchangeOnlineInternalSession*"}

if ($existingPSSession.count -gt 0)
{
   for ($index = 0; $index -lt $existingPSSession.count; $index++)
   {
      $session = $existingPSSession[$index]
      Remove-PSSession -session $session

      Write-Information "Removed the PSSession $($session.Name) connected to $($session.ComputerName)"

      # Remove any active access token from the cache
      # If the connectionId of the session being cleared is equal to AppSettings.ConnectionId, this means connection to EXO cmdlets will break.
      if ($session.ConnectionContext.ConnectionId -ieq [Microsoft.Exchange.Management.AdminApiProvider.AppSettings]::ConnectionId)
      {
         Clear-ActiveToken -TokenProvider $session.TokenProvider -IsSessionUsedByInbuiltCmdlets:$true
      }
      else
      {
         Clear-ActiveToken -TokenProvider $session.TokenProvider -IsSessionUsedByInbuiltCmdlets:$false
      }

Es gibt also ein Commandlet "Clear-ActiveToken", welches im Modul "Microsoft.Exchange.Management.ExoPowershellGalleryModule.dll" verborgen ist und hier erst geladen wird.

$ConnectionContexts

Eine zweite interessante Stelle ist

# Get all the connection contexts so that the logger can be initialized.
$connectionContexts = [Microsoft.Exchange.Management.ExoPowershellSnapin.ConnectionContextFactory]::GetAllConnectionContexts()
$disconnectCmdletId = [System.Guid]::NewGuid().ToString()

Mich hat natürlich interessiert, was die Properties sind. In meinem Fall hab es nur genau einen "CconnectionContext" hat

PS C:> $connectionContexts.count
1

Interessant sind aber einige Properties. die ich in eigenen Skripten auch gut abfragen kann.

PS C:\> $connectionContexts | fl *

ClientAppId                     : 604d9047-44e5-443a-ad8f-98abe5748b0a
SessionPrefixName               : ExchangeOnlineInternalSession_
ConnectionUri                   : https://outlook.office365.com
PowerShellConnectionUri         : https://outlook.office365.com:443/PowerShell-LiveID?BasicAuthToOAuthConversion=true&HideBannerMessage=true&ConnectionId=6e
                                  a29c98-b77e-4cd6-a7db-f35c73708464&ClientProcessId=27748&ExoModuleVersion=3.3.0&OSVersion=Microsoft+Windows+NT+10.0.22631.0
AzureAdAuthorizationEndpointUri : https://login.microsoftonline.com/organizations
NewEXOModuleBaseUri             : https://outlook.office365.com/AdminApi/v1.0
AdminApiBetaBaseUri             : https://outlook.office365.com/AdminApi/beta
ExoModuleVersion                : 3.3.0
ExchangeEnvironmentName         : O365Default
IsCertBasedFlow                 : False
IsCloudShellEnvironment         : False
TokenProvider                   : Microsoft.Exchange.Management.AdminApiProvider.Authentication.MSALTokenProvider
PowerShellCredentials           : System.Management.Automation.PSCredential
PowerShellTokenInfo             : Microsoft.Exchange.Management.AdminApiProvider.Authentication.TokenInformation
TokenExpiryTime                 : 03.12.2023 17:21:52 +00:00
CommandName                     : {*}
FormatTypeName                  : {*}
Prefix                          :
IsRpsSession                    : False
IsEopSession                    : False
ConnectionName                  : ExchangeOnline_1
PageSize                        : 1000
Logger                          : Microsoft.Online.CSE.RestApiPowerShellModule.Instrumentation.CmdletLogger
UserPrincipalName               : adminfcdev@msxfaqdev.onmicrosoft.com
TenantId                        : fb78d390-0c51-40cd-8e17-fdbfab77341b

So kann ich von einer bestehenden Verbindung z.B. sehr schnell ermitteln, mit welcher Identität die Verbindung zu welchem Tenant aufgebaut wurde. Aber es gibt weitere Properties, die ich mich neugierig gemacht haben.

PowerShellTokenInfo

Das Property PowerShellTokenInfo habe ich mir genauer angeschaut und dort ist tatsächlich der UPN, die TenantID und das OAUTH-Token und die Gültigkeit

PS C:\> $connectionContexts.PowerShellTokenInfo | fl

UserPrincipalName : adminfcdev@msxfaqdev.onmicrosoft.com
AuthorizationHeader : Bearer eyJ0eXAiOiJKV1QiLCJub25............2Ag9
FWuaeeji45oFsjry5dNDv985fMd6Bk77GJ6lw8g
TenantId : 604d9047-44e5-443a-ad8f-98abe5748b0a
ExpiresOn : 03.12.2023 17:21:52 +00:00

Das OAUTH-Token im Feld "AuthorizationHeader" kann ich auch einfach mittels JWT.IO oder JWT.MS decodieren. Das Token ist ca. 26h! gültig und die "Expiration"-Zeit passt um Feld "ExpiresOn".

Die weiteren Felder (gekürzt) sind:

{
  "aud": "https://outlook.office365.com",
  "iss": "https://sts.windows.net/604d9047-44e5-443a-ad8f-98abe5748b0a/",
  "iat": 1701531721,
  "nbf": 1701531721,
  "exp": 1701624111,
  "amr": ["pwd","mfa"],
  "app_displayname": "Microsoft Exchange REST API Based Powershell",
  "appid": "fb78d390-0c51-40cd-8e17-fdbfab77341b",
...
  "scp": "AdminApi.AccessAsUser.All 
          FfoPowerShell.AccessAsUser.All 
          RemotePowerShell.AccessAsUser.All 
          VivaFeatureAccessPolicy.Manage.All",
...
  "upn": "adminfcdev@msxfaqdev.onmicrosoft.com",
...
}

Mit dem Token kann ich nun natürlich auch eigene Invoke-WebRequest/Invoke-RestMethod-Aktionen auslösen, wenn z.B. bestimmte Funktionen nicht per PowerShell-Commandlet bereitgestellt sind.

Achtung:
Wenn ich als Benutzer das Token so auslesen kann, dann kann es jedes Programm, welches in dieser PowerShell von mir gestartet wird. Achten Sie daher darauf, welche anderen Module sie nachladen oder welche anderen Commandlets sie aufrufen und damit in diesem Prozess aktivieren. Ich warte nur darauf, bis ein Modul in der PSGallery o.ä. so Zugangsdaten gewinnt.

Ich habe danach die Session absichtlich länger als die 26h bestehen lassen und quasi zwei Tage später mir die Daten noch einmal angeschaut. Sie haben sich NICHT verändert, d.h. es gibt keinen Hintergrundprozess, welcher das Token bei Ablauf erneuert. Ich habe dann mit einem "Get-Mailbox" neue Daten angefordert aber selbst diese Aktion hat das Token nicht erneuert!

Das habe ich aber schon mehrfach gesehen, dass das Token nach der Anmeldung nur genutzt wird um einen Sessioncookie zu erhalten und danach das eigentliche AUTH-Token nicht mehr genutzt wird.

Logging

Bei der Analyse des Codes sind mir ein paar weitere Zeilen aufgefallen, die anscheinend eine Protokollierung bereitstellen.

foreach ($context in $connectionContexts)
{
   $context.Logger.InitLog($disconnectCmdletId);
   $context.Logger.LogStartTime($disconnectCmdletId, $startTime);
   $context.Logger.LogCmdletName($disconnectCmdletId, "Disconnect-ExchangeOnline");
...

Das "Logger"-Objekt hat dabei den Typ "Microsoft.Online.CSE.RestApiPowerShellModule.Instrumentation.CmdletLogger" mit folgenden Methoden und einem Property.

PS C:\> $connectionContexts.logger | gm

   TypeName: Microsoft.Online.CSE.RestApiPowerShellModule.Instrumentation.CmdletLogger

Name                  MemberType Definition
----                  ---------- ----------
CommitLog             Method     void CommitLog(string cmdletId), void ICmdletLogger.CommitLog(string cmdletId)
Equals                Method     bool Equals(System.Object obj)
GetCurrentLogFilePath Method     string GetCurrentLogFilePath(), string ICmdletLogger.GetCurrentLogFilePath()
GetHashCode           Method     int GetHashCode()
GetType               Method     type GetType()
InitLog               Method     void InitLog(string cmdletId), void ICmdletLogger.InitLog(string cmdletId)
LogCmdletName         Method     void LogCmdletName(string cmdletId, string name), void ICmdletLogger.LogCmdletName(string cmdletId, string name)
LogCmdletParameters   Method     void LogCmdletParameters(string cmdletId, string parameters), void LogCmdletParameters(string cmdletId, System.Collections…
LogEndTime            Method     void LogEndTime(string cmdletId, datetime time), void ICmdletLogger.LogEndTime(string cmdletId, datetime time)
LogGenericError       Method     void LogGenericError(string cmdletId, string error), void LogGenericError(string cmdletId, System.Management.Automation.Er…
LogGenericInfo        Method     void LogGenericInfo(string cmdletId, string info), void ICmdletLogger.LogGenericInfo(string cmdletId, string info)
LogStartTime          Method     void LogStartTime(string cmdletId, datetime time), void ICmdletLogger.LogStartTime(string cmdletId, datetime time)
ToString              Method     string ToString()
LogStore              Property   System.Collections.Concurrent.ConcurrentDictionary[string,Microsoft.Online.CSE.RestApiPowerShellModule.Instrumentation.ICm…

Ich kann sogar ohne Fehler eine Message senden.

PS C:\> $connectionContexts.logger.LogGenericInfo("MSXFAQ","Testmessage to logger")

Allerdings weiß ich noch nicht, wo Sie letztlich ankommt.

Im Hauptskript "ExchangeOnlineManagement.psm1" findet sich dazu eine ähnliche Logik:

$cmdletLogger = New-CmdletLogger `
                   -ExoModuleVersion $moduleVersion `
                   -LogDirectoryPath $LogDirectoryPath.Value `
                   -EnableErrorReporting:$EnableErrorReporting.Value `
                   -ConnectionId $connectionContextID `
                   -IsRpsSession:$UseRPSSession.IsPresent
$logFilePath = $cmdletLogger.GetCurrentLogFilePath()
if ($EnableErrorReporting.Value -eq $true -and $UseRPSSession -eq $false)
{
   Write-Message ("Writing cmdlet logs to " + $logFilePath)
}

Auch die Commandlets "New-CmdletLogger" oder "Write-Message" gehören nicht zum PowerShell Standard.

Interessanterweise gibt es noch eine weitere Protokollierungsfunktion.

Init-Log -CmdletId $CmdletRequestId;
$startTime = Get-Date
Log-Column -ColumnName StartTime -CmdletId $CmdletRequestId -Value $startTime
Log-Column -ColumnName CmdletName -CmdletId $CmdletRequestId -Value $MyInvocation.MyCommand.Name
Log-Column -ColumnName CmdletParameters -CmdletId $CmdletRequestId -Value $MyInvocation.BoundParameters
Log-Column -ColumnName GenericError -CmdletId $CmdletRequestId -Value $_
Log-Column -ColumnName EndTime -CmdletId $CmdletRequestId -Value $endTime
Commit-Log -CmdletId $CmdletRequestId
$global:EXO_LastExecutionStatus = $false;

Das macht mir eher den Eindruck, das hier irgendeine "Zeiler einer Tabelle" erstellt und dann gespeichert wird. Leider gibt es zu der Funktion auch keine Dokumentation und sie ist auch nicht exportiert. Eine Suche im Modulverzeichnis "%userdata%\OneDrive\Dokumente\PowerShell\Modules\ExchangeOnlineManagement" nach dem String hat aber erst einmal nicht geliefert. Aber ein anderes Property hat mich dann auf die richtige Spur geschickt:

PS C:\> $connectionContexts.ModuleName
C:\Users\fcarius\AppData\Local\Temp\tmpEXO_nvpskj4n.eem

In der Datei sind die Funktionen definiert und es ist schnell zu sehen, dass "Init-Log", "Log-Column" u.a. nur Hilfsfunktionen sind, um am Ende doch wieder "$connectionContexts.Logger" aufzurufen. Ein Mitschnitt mit Fiddler hat aber keine sichtbare Verbindung zur Cloud gezeigt.

Vielleicht kann ja jemand meiner Leser*innen hier etwas beisteuern, was sich hinter der Klasse "Microsoft.Online.CSE.RestApiPowerShellModule.Instrumentation.CmdletLogger" im Modul

Push-EXOTelemetryRecord

Gerade in Europa und insbesondere in Deutschland ist "Telemetrie" schon fast ein Alarmwort, auf das sich alle direkt stürzen. Dabei übersehen viele Menschen, dass allein die Nutzung von Smartphones und insbesondere der "sozialen Dienste", also Facebook, Twitter, TikTok etc viel mehr verwertbare Spuren hinterlassen, als die Sammlung von Aufrufen von Produkten zur berechtigten Fehlerbehebung, Qualitätssicherung und Verwendungsoptimierung. Das wird gerne als "Telemetrie" oder "Application Insights" bezeichnet aber die meisten PowerShell Entwickler hätten das vielleicht gerne aber über mehr als ein "Start-Transcript" oder einigen "Write-Host"-Ausgaben geht es dann noch nicht hinaus.

Für Microsoft ist es aber schon interessant zu wissen, welche Befehle wie genutzt werden. Daher überrascht es mich auch nicht, in dem Code entsprechende Hinweise  zu solchen Funktionen zu finden, z.B.:

if ($rpsConnectionWithErrorReportingExists)
{
   if ($global:_EXO_TelemetryFilePath -eq $null)
   {
      $global:_EXO_TelemetryFilePath = New-EXOClientTelemetryFilePath
   }

   # Log errors which are encountered during Disconnect-ExchangeOnline execution.
   Write-Message ("Writing Disconnect-ExchangeOnline errors to " + $global:_EXO_TelemetryFilePath)
   Push-EXOTelemetryRecord `
      -TelemetryFilePath $global:_EXO_TelemetryFilePath  `
      -CommandName Disconnect-ExchangeOnline  `
      -CommandParams $PSCmdlet.MyInvocation.BoundParameters  `
      -OrganizationName  $global:_EXO_ExPSTelemetryOrganization  `
      -ScriptName $global:_EXO_ExPSTelemetryScriptName   `
      -ScriptExecutionGuid $global:_EXO_ExPSTelemetryScriptExecutionGuid  `
      -ErrorObject $global:Error  `
      -ErrorRecordsToConsider ($errorCountAtProcessEnd  `
      -$errorCountAtStart)
}
#Auszug aus C:\Users\username\OneDrive\Dokumente\PowerShell\Modules\ExchangeOnlineManagement\3.3.0\netCore\ExchangeOnlineManagement.psm1
# Where to store EXO command telemetry data. By default telemetry is stored in the directory "%TEMP%/EXOTelemetry" in the file : EXOCmdletTelemetry-yyyymmdd-hhmmss.csv.
$LogDirectoryPath = New-Object System.Management.Automation.RuntimeDefinedParameter('LogDirectoryPath', [string], $attributeCollection)
$LogDirectoryPath.Value = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "EXOCmdletTelemetry")

Aber auch hier habe ich auf die Schnelle nichts gefunden, wie die EXO-PowerShell diese Daten dann letztlich zu Microsoft oder einem lokalen Log schreibt. Ich hatte mir eigentlich erhofft, dass ich hier den ein oder anderen Kniff zur Nutzung von Telemetrie in meinen eigenen Skripten abschauen könnte.

Weitere Links