Search code examples
powershelloffice365exchange-server

Using System.Management.Automation and an exported session, how do I specify credentials for an O365 call?


I'm building a small console app that uses constructs in the System.Management.Automation namespace to connect to ExchangeOnline and perform various tasks. The overhead time of creating and importing a new session with each run during my dev & test is prohibitive.

Thus, I've elected to save the session to disk using Export-PSSession. This all works fine from a PowerShell prompt, like so:

Import-Module ExchangeOnline
Get-Mailbox

I'm prompted for my credentials, and off we go.

Unfortunately, the same can't be said for running the same sequence under Automation:

System.Management.Automation.MethodInvocationException: Exception calling "GetSteppablePipeline" with "1" argument(s): "Exception calling "PromptForCredential" with "4" argument(s): "A command that prompts the user failed because the host program or the command type does not support user interaction. The host was attempting to request confirmation with the following message: Enter your credentials for https://outlook.office365.com/powershell-liveid/.""

How do I send my credentials to O365 when using System.Management.Automation?

This Q&A almost answers it, but not quite.

Here's my code.

Implementation

Friend Class Monad
  Implements IDisposable

  Public Sub New()
    Me.SessionState = InitialSessionState.CreateDefault
    Me.Monad = PowerShell.Create
  End Sub

  Public Sub ImportModule(Modules As String())
    If Me.RunSpace.IsNotNothing Then
      Me.RunSpace.Dispose()
      Me.RunSpace = Nothing
    End If

    Me.SessionState.ImportPSModule(Modules)
    Me.RunSpace = RunspaceFactory.CreateRunspace(Me.SessionState)
    Me.RunSpace.Open()
    Me.Invoker = New RunspaceInvoke(Me.RunSpace)
  End Sub

  Public Function ExecuteScript(Script As String) As Collection(Of PSObject)
    Dim oErrors As Collection(Of ErrorRecord)

    ExecuteScript = Me.Invoker.Invoke(Script)

    oErrors = Me.Monad.Streams.Error.ReadAll

    If oErrors.Count > 0 Then
      Throw New PowerShellException(oErrors)
    End If
  End Function

  Protected Overridable Sub Dispose(IsDisposing As Boolean)
    If Not Me.IsDisposed Then
      If IsDisposing Then
        If Me.RunSpace.IsNotNothing Then Me.RunSpace.Dispose()
        If Me.Invoker.IsNotNothing Then Me.Invoker.Dispose()
        If Me.Monad.IsNotNothing Then Me.Monad.Dispose()

        Me.RunSpace = Nothing
        Me.Invoker = Nothing
        Me.Monad = Nothing
      End If
    End If

    Me.IsDisposed = True
  End Sub

  Public Sub Dispose() Implements IDisposable.Dispose
    Me.Dispose(True)
  End Sub

  Private ReadOnly SessionState As InitialSessionState

  Private IsDisposed As Boolean
  Private RunSpace As Runspace
  Private Invoker As RunspaceInvoke
  Private Monad As PowerShell
End Class

Call

Friend Function GetMailbox() As IEnumerable(Of PSObject)
  Using oMonad As New Monad
    oMonad.ImportModule({"ExchangeOnline"})

    Return oMonad.ExecuteScript("Get-Mailbox")
  End Using
End Function

Solution

  • The ExchangeOnline module has some issues with that. It wants to be able to display an interactive modern authentication dialog, and there's no reliable way to stop it. You can feed it credentials, but it will barf if it needs to display an interactive dialog (as it does for MFA).

    For storing and retrieving the credentials, you can use ConvertFrom-SecureString and Export-Csv or Export-CliXml as in the answer from InteXX, but that will stop working if basic authentication is disabled for the account you're using, or after 13 October 2020 when basic auth is disabled in exchange online (see KB4521831 ref https://support.microsoft.com/en-us/help/4521831/exchange-online-deprecating-basic-auth). Until then, you can also use a module like VaultCredential to manage credentials (note that it won't work across accounts).

    So the next question is probably how you get a token for modern auth and how you present it to Exchange Online to authenticate with powershell. That's not hard at all.

    You can bang against the endpoints with .NET web calls without too much effort, but it's easier to use one of the officially sanctioned libraries like ADAL.PS (deprecated) and MSAL.PS. Go ahead and Install-Module -Name MSAL.PS from the powershell gallery. Once it's there (and run Get-Module -Refresh -ListAvailable to make sure it can autoload) you can run:

    $Token = Get-MsalToken -ClientId "a0c73c16-a7e3-4564-9a95-2bdf47383716" -RedirectUri "urn:ietf:wg:oauth:2.0:oob" -Scopes "https://outlook.office365.com/AdminApi.AccessAsUser.All","https://outlook.office365.com/FfoPowerShell.AccessAsUser.All","https://outlook.office365.com/RemotePowerShell.AccessAsUser.All"
    

    You can also add "-LoginHint " (i.e. probably your email address) to skip the initial locator prompt, and it might help to add "-TenantId '00000000-0000-0000-0000-000000000000'" (except use the actual GUID for your Azure AD tenant).

    Once a token is acquired, MSAL will keep it in its cache and automatically refresh it for you. This might make non-interactive use a little more difficult. You can review preferred ways to deal with it (https://learn.microsoft.com/en-us/azure/active-directory/develop/msal-acquire-cache-tokens), or work with the libraries or interfaces at a lower level (such as using AcquireTokenSilentAsync()).

    To actually use the token against Exchange Online, you need to use basic auth. It will still work even after they deprecate it, but it won't validate your account's password, it will only do other stuff like accept encoded tokens. Basically you need to Get-PsSession -Credential $EncodedBasicCredential where $EncodedBasicCredential is constructed with your UPN as the username and the Base64-encoded authorization header value as the password. For example:

    $EncodedBasicCredential = [System.Management.Automation.PSCredential]::new($Token.account.username,(ConvertTo-SecureString -AsPlainText -Force -String ($Token.CreateAuthorizationHeader())))
    

    Note that $Token.CreateAuthorizationHeader() just takes the value of $Token.AccessToken and prepends it with "Bearer ". Now all you need to do is create a New-PsSession with the appropriate ConnectionUri, ConfigurationName, and your credential object:

        $ExchangeOnlineSession = New-PSSession -Name ExchangeOnline -ConnectionUri "https://outlook.office365.com/PowerShell-LiveId?BasicAuthToOAuthConversion=true" -ConfigurationName Microsoft.Exchange -Credential $EncodedBasicCredential -Authentication Basic
    

    And you can import that to the parent session as you like:

    $ExchangeOnlineModule = Import-PSSession -Session ($ExchangeOnlineSession) -WarningAction Ignore
    

    To answer your specific question (how you specify credentials), it all depends on how you access your identity authority. If you are using ADFS on premises and your script runs on premises, then you should be able to run the process as the desired identity and Get-MsalToken will automatically use integrated windows authentication against ADFS without prompting you. If you are using PTA or native auth directly against Azure AD, then you'll need to look at creating a client application and using a secret or a certificate to authenticate against Azure AD to get your token.

    I think this will be reasonably easy to translate from powershell to C#, but I'm a scripting sys admin, not a coder.