public/Connect-MsecExchangeOnline.ps1

function Connect-MsecExchangeOnline {
    <#
    .SYNOPSIS
        Signs the ExchangeOnlineManagement module in using the msec session's token, so
        Get-EXO* commands run as the msec app without its private key leaving Key Vault.

    .DESCRIPTION
        Exchange Online is NOT Graph, and cannot be reached through it. Mailbox permissions -
        Full Access, Send As - are an Exchange concept with no Graph equivalent: there is no
        /users/{id}/mailboxPermissions endpoint, and no plan to add one. So the
        ExchangeOnlineManagement module is the only way to answer "who can read this shared
        mailbox", and this command exists to authenticate it the way msec authenticates
        everything else.

        THE TOKEN IS FOR outlook.office365.com, NOT GRAPH. Exchange issues its own audience, so
        a Graph token is rejected here and vice versa. Both are acquired the same way - a JWT
        client assertion signed inside Key Vault - which is what keeps the private key off this
        machine.

        EXCHANGE NEEDS A DIRECTORY ROLE, NOT JUST AN APP ROLE, and this is the step people
        miss. Exchange.ManageAsApp on the application is necessary but NOT sufficient: the
        service principal must also hold a directory role - Exchange Administrator, Exchange
        Recipient Administrator, or Global Reader for read-only work. Without it every cmdlet
        fails with an authorisation error that names no missing permission, because from
        Exchange's point of view the app is authenticated and simply has no rights.

        THE MODULE IS NOT AN msec DEPENDENCY. It is imported only when this command is called,
        so msec installs and runs normally on a machine that has never heard of Exchange.

    .PARAMETER Organization
        The tenant's primary domain, e.g. contoso.onmicrosoft.com or contoso.com. Exchange
        identifies the tenant by domain rather than by id, and app-only connections require it.

    .PARAMETER MinimumMinutes
        Fail unless the token has at least this long left. Default 5. A long mailbox
        enumeration should ask for the time it needs: the module is handed a static token and
        cannot renew it, so a short one starts working and then fails partway through.

    .PARAMETER ShowBanner
        Show the module's own connection banner, which is suppressed by default because it is
        noise in a pipeline log.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid>
        Connect-MsecExchangeOnline -Organization contoso.onmicrosoft.com
        Get-EXOMailbox -RecipientTypeDetails SharedMailbox

    .EXAMPLE
        # A long run, refusing to start without headroom.
        Connect-MsecExchangeOnline -Organization contoso.com -MinimumMinutes 30

    .NOTES
        Needs Connect-Msec first, and the ExchangeOnlineManagement module - which is NOT a
        dependency of msec.

        Disconnect with Disconnect-ExchangeOnline. The module holds a session; leaving it open
        across a long script is fine, but leaving it open across tenants is not.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Organization,

        [ValidateRange(0, 60)]
        [int] $MinimumMinutes = 5,

        [switch] $ShowBanner
    )

    Assert-MsecSession

    if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
        throw 'ExchangeOnlineManagement is required for Connect-MsecExchangeOnline. Install with: Install-Module ExchangeOnlineManagement -Scope CurrentUser'
    }
    Import-Module ExchangeOnlineManagement -ErrorAction Stop

    # Exchange Online's own audience. Commercial only - the sovereign clouds use different
    # hosts, and guessing one would produce a token Exchange rejects with an error that looks
    # like a permission problem.
    $resource = 'https://outlook.office365.com'
    $environment = $script:MsecSession.Endpoints.EnvironmentName
    if ($environment -and $environment -ne 'AzureCloud') {
        Write-Warning "The msec session is in '$environment'. Connect-MsecExchangeOnline assumes the commercial Exchange endpoint ($resource), which is probably wrong for this cloud - pass the right one by connecting with ExchangeOnlineManagement directly if this fails."
    }

    $token = Get-MsecAccessToken -Resource $resource

    # Checked AFTER acquisition: acquiring is what refreshes a stale token, so testing the
    # cache first would refuse over one the next line would have replaced.
    $expiry = $script:MsecSession.Tokens[$resource].ExpiresOn
    if ($expiry) {
        $left = [int] ($expiry - [DateTimeOffset]::UtcNow).TotalMinutes
        if ($left -lt $MinimumMinutes) {
            throw "The Exchange token has $left minute(s) left, which is under the $MinimumMinutes requested. The module is handed a static token and cannot renew it, so a longer run would start working and then fail partway through. Run Connect-Msec again for a fresh token."
        }
        Write-Verbose "Exchange token valid for about $left more minute(s)."
    }

    # -AccessToken is a plain String here, unlike Connect-MgGraph which takes a SecureString.
    $connectParams = @{
        AccessToken  = $token
        Organization = $Organization
        AppId        = $script:MsecSession.ClientId
        ErrorAction  = 'Stop'
    }
    if (-not $ShowBanner) { $connectParams['ShowBanner'] = $false }

    Connect-ExchangeOnline @connectParams

    Write-Verbose "Exchange Online connected to $Organization as app $($script:MsecSession.ClientId) (app-only)."
}