Public/Connect-METSession.ps1
|
function Connect-METSession { [CmdletBinding(DefaultParameterSetName = 'Interactive')] param( [Parameter(ParameterSetName = 'Interactive')] [string] $UserPrincipalName, [Parameter(ParameterSetName = 'Interactive')] [switch] $DisableWAM, [Parameter(ParameterSetName = 'Interactive')] [switch] $UseDeviceAuthentication, [Parameter(ParameterSetName = 'ServicePrincipal', Mandatory)] [string] $AppId, # Must be the tenant's primary .onmicrosoft.com domain name, not the tenant GUID, when # connecting Exchange Online - Connect-ExchangeOnline's -Organization parameter for # app-only authentication rejects GUIDs outright. Graph and Teams accept either form. [Parameter(ParameterSetName = 'ServicePrincipal', Mandatory)] [string] $TenantId, [Parameter(ParameterSetName = 'ServicePrincipal')] [string] $CertificateThumbprint, [Parameter(ParameterSetName = 'ServicePrincipal')] [string] $CertificatePath, [Parameter(ParameterSetName = 'ServicePrincipal')] [System.Security.SecureString] $CertificatePassword, [Parameter(ParameterSetName = 'ManagedIdentity', Mandatory)] [switch] $ManagedIdentity, [Parameter()] [string] $DelegatedOrganization, [Parameter()] [switch] $SkipExchangeOnline, [Parameter()] [switch] $SkipGraph, [Parameter()] [switch] $SkipTeams ) if ($PSCmdlet.ParameterSetName -eq 'ServicePrincipal') { if ($CertificateThumbprint -and $CertificatePath) { throw 'Specify either -CertificateThumbprint or -CertificatePath, not both.' } if (-not $CertificateThumbprint -and -not $CertificatePath) { throw 'ServicePrincipal authentication requires either -CertificateThumbprint (Windows certificate store) or -CertificatePath (works on any platform, including Linux/macOS/Codespaces).' } if ($CertificatePath -and -not $CertificatePassword) { throw '-CertificatePath requires -CertificatePassword.' } if ($CertificatePath) { # Connect-ExchangeOnline's own CertificateFilePath validation calls the raw .NET # File.Exists() on this string, which - unlike PowerShell's own path cmdlets - never # expands '~' or resolves a relative path. Left alone, a path like '~/cert.pfx' fails # with the generic, misleading "Certificate is not accessible to the current user." # Resolving to an absolute path once here fixes it for the EXO, Graph, and Teams legs. $resolvedCertPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CertificatePath) if (-not (Test-Path -LiteralPath $resolvedCertPath -PathType Leaf)) { throw "-CertificatePath '$CertificatePath' does not exist (resolved to '$resolvedCertPath')." } $CertificatePath = $resolvedCertPath } if (-not $SkipExchangeOnline -and $TenantId -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "-TenantId must be the tenant's primary .onmicrosoft.com domain name (e.g. 'contoso.onmicrosoft.com'), not the tenant GUID - Connect-ExchangeOnline's -Organization parameter for app-only authentication rejects GUIDs. Find it in the Entra admin center under Overview > 'Primary domain', or pass -SkipExchangeOnline if you only need Graph/Teams." } } # Storm-2372 and follow-on campaigns (Microsoft Security Blog, Feb 2025 - Apr 2026) abuse the # device-code flow's legitimate UX: an attacker generates a real device code and social-engineers # a victim into entering it, handing over a fully-authenticated session with no credential theft # or MFA bypass needed. Microsoft's own current guidance: "block device code flow wherever # possible... allow only where necessary." if ($UseDeviceAuthentication) { Write-Warning 'Device code authentication requested (-UseDeviceAuthentication). This flow is a documented phishing vector (Microsoft: "block wherever possible, allow only where necessary") - use it only when no browser is reachable at all (a true headless host). Prefer -DisableWAM on an interactive host, or -CertificatePath for unattended/CI use.' } $requestedMode = $PSCmdlet.ParameterSetName $requestedOrg = switch ($requestedMode) { 'ServicePrincipal' { $TenantId } default { if ($DelegatedOrganization) { $DelegatedOrganization } else { $null } } } # Cross-call guard within the same PowerShell process: if a prior Connect-METSession call in # this session already established a different identity, later legs below may reuse EXO/Graph/ # Teams connections that Connect-METSession itself doesn't have independent proof are wrong-tenant # (this is the only reliable check available for Graph/Teams in Interactive+DelegatedOrganization # mode, since neither SDK's returned context exposes the domain name originally requested - only # a resolved tenant GUID). EXO gets a second, fully independent check below regardless. if ($script:METConnection -and ($requestedMode -ne $script:METConnection.Mode -or ($requestedOrg -and $script:METConnection.Org -and $requestedOrg -ne $script:METConnection.Org))) { $previousIdentity = if ($script:METConnection.Org) { $script:METConnection.Org } else { $script:METConnection.Mode } $newIdentity = if ($requestedOrg) { $requestedOrg } else { $requestedMode } throw "Connect-METSession already established a session in this PowerShell process for '$previousIdentity'. Requesting '$newIdentity' now would reuse that connection's Exchange Online/Graph/Teams sessions without actually switching tenant or auth mode. Run Disconnect-METSession first, then reconnect to the new organization." } # User.Read.All is deliberately not requested: the only Graph call sites are # Expand-METGroupMembership.ps1 (Get-MgGroup/Get-MgGroupTransitiveMember, covered by # Group.Read.All) and MET-Teams014 (Get-MgPolicyCrossTenantAccessPolicyDefault/ # Get-MgPolicyAuthorizationPolicy, covered by Policy.Read.All) - neither needs it, and # least-privilege scoping matters for an interactive admin consent prompt. $graphScopes = @( 'Policy.Read.All' 'Organization.Read.All' 'Group.Read.All' ) $servicesConnected = [System.Collections.Generic.List[string]]::new() # Exchange Online must connect first. Each module carries its own # Microsoft.Identity.Client (MSAL) build, only one of which can occupy the # default AssemblyLoadContext. ExchangeOnlineManagement ships the newest # (4.83.1 vs Graph's 4.82.1), and .NET resolves a lower version request # against a higher loaded one but never the reverse. Connecting Graph first # pins the older MSAL and Exchange then fails with 0x80131040. This only # protects against a conflict this function causes itself - if the caller's # session already loaded a conflicting MSAL build before ever calling # Connect-METSession (e.g. a prior Import-Module MicrosoftTeams/Graph/Az), # the check below surfaces that instead of the raw MSAL load failure. if (-not $SkipExchangeOnline) { $exoModule = Get-Module -ListAvailable -Name ExchangeOnlineManagement | Where-Object { $_.Version -ge [version]'3.0.0' } | Select-Object -First 1 if (-not $exoModule) { throw "ExchangeOnlineManagement 3.x or later is not installed. Run: Install-Module ExchangeOnlineManagement -Scope CurrentUser" } $exoParams = @{ ShowBanner = $false ShowProgress = $false SkipLoadingFormatData = $true SkipLoadingCmdletHelp = $true } if ($UserPrincipalName) { $exoParams['UserPrincipalName'] = $UserPrincipalName } if ($DisableWAM) { $exoParams['DisableWAM'] = $true } if ($UseDeviceAuthentication) { $exoParams['Device'] = $true } if ($DelegatedOrganization) { $exoParams['DelegatedOrganization'] = $DelegatedOrganization } switch ($PSCmdlet.ParameterSetName) { 'ServicePrincipal' { $exoParams['AppId'] = $AppId $exoParams['Organization'] = $TenantId if ($CertificatePath) { $exoParams['CertificateFilePath'] = $CertificatePath $exoParams['CertificatePassword'] = $CertificatePassword } else { $exoParams['CertificateThumbprint'] = $CertificateThumbprint } } 'ManagedIdentity' { $exoParams['ManagedIdentity'] = $true } } $existing = Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Connected' } | Select-Object -First 1 if ($existing) { # Reusing a live connection without checking whose tenant it belongs to is a # cross-customer data leak for -DelegatedOrganization/MSSP usage: run against # customer A, forget to disconnect, run against customer B - the report gets # labeled B but reads A's actual configuration. Organization/DelegatedOrganization # directly reflect what Connect-ExchangeOnline was told to connect to, regardless # of which one applies for this auth mode, so checking both covers every case. if ($requestedOrg -and $existing.Organization -ne $requestedOrg -and $existing.DelegatedOrganization -ne $requestedOrg) { $existingOrg = if ($existing.DelegatedOrganization) { $existing.DelegatedOrganization } else { $existing.Organization } throw "Exchange Online is already connected to '$existingOrg' (as $($existing.UserPrincipalName)), not the requested organization '$requestedOrg'. Run Disconnect-METSession first, then reconnect to the correct organization." } # CertificateAuthentication is only ever set for CBA connections (Microsoft's own # Get-ConnectionInformation docs: "the AppId parameter ... for CBA connections"), so it # can't distinguish interactive from Managed Identity - both are app-only, but only one # is certificate-based. UserPrincipalName is populated for every interactive sign-in and # never for an app-only session (CBA or Managed Identity alike), so it works for both. if ($PSCmdlet.ParameterSetName -in @('ServicePrincipal', 'ManagedIdentity') -and $existing.UserPrincipalName) { throw "Exchange Online is already connected interactively (as $($existing.UserPrincipalName)), not via app-only authentication. Run Disconnect-METSession first, then reconnect with -CertificateThumbprint/-CertificatePath or -ManagedIdentity." } } if (-not $existing) { # A different MSAL version already loaded in-process (e.g. from an # earlier Import-Module MicrosoftTeams/Graph/Az in this session) can # never be reconciled by connect order alone - .NET cannot unload or # replace an assembly once loaded. Detect that case up front so the # error names the real cause instead of surfacing MSAL's opaque # 0x80131040 manifest-mismatch failure. $requiredMsalVersion = $null if ($exoModule.ModuleBase) { $exoMsalPath = Join-Path $exoModule.ModuleBase 'netCore' 'Microsoft.Identity.Client.dll' $requiredMsalVersion = Get-METAssemblyFileVersion -Path $exoMsalPath } if ($requiredMsalVersion) { $conflict = Test-METAssemblyLoadConflict -AssemblyName 'Microsoft.Identity.Client' -RequiredVersion $requiredMsalVersion if ($conflict) { throw "Failed to connect to Exchange Online: $conflict" } } try { Write-Verbose 'Connecting to Exchange Online...' Connect-ExchangeOnline @exoParams $servicesConnected.Add('ExchangeOnline') } catch { $onWindowsRetry = if ($IsWindows) { "On Windows try: Connect-METSession -DisableWAM -UserPrincipalName <upn> -Verbose" } else { "On a headless host with no reachable browser try: Connect-METSession -UseDeviceAuthentication -Verbose`nOtherwise try: Connect-METSession -DisableWAM -Verbose" } throw "Failed to connect to Exchange Online: $_`n$onWindowsRetry" } } else { $servicesConnected.Add('ExchangeOnline') Write-Verbose "Exchange Online already connected as $($existing.UserPrincipalName)." } } if (-not $SkipGraph) { $graphModuleMissing = @( 'Microsoft.Graph.Identity.SignIns' 'Microsoft.Graph.Groups' ) | Where-Object { -not (Get-Module -ListAvailable -Name $_ | Where-Object { $_.Version -ge [version]'2.0.0' }) } if ($graphModuleMissing) { Write-Warning "Optional Graph module(s) not installed: $($graphModuleMissing -join ', '). Group-membership expansion will fall back to Exchange Online cmdlets. Install with: Install-Module '$($graphModuleMissing[0])' -Scope CurrentUser" } else { $graphParams = @{ Scopes = $graphScopes; NoWelcome = $true } if ($UseDeviceAuthentication -and $PSCmdlet.ParameterSetName -eq 'Interactive') { $graphParams['UseDeviceCode'] = $true } if ($DelegatedOrganization -and $PSCmdlet.ParameterSetName -eq 'Interactive') { # Connect-MgGraph's UserParameterSet accepts -TenantId for exactly this # (CSP/GDAP delegated-admin) scenario - previously this was silently ignored # for Graph, so a delegated-org run could authenticate against the operator's # own home tenant instead of the customer's. $graphParams['TenantId'] = $DelegatedOrganization } # Certificate loading happens before the try/catch below so it must be guarded # separately - otherwise a bad -CertificatePath/-CertificatePassword throws # uncaught and aborts the whole Connect-METSession call (including the Teams leg, # which runs after this one), contradicting the "a failed Graph connection is # non-fatal" design every other failure path here follows. $graphCertLoadError = $null switch ($PSCmdlet.ParameterSetName) { 'ServicePrincipal' { $graphParams = @{ ClientId = $AppId TenantId = $TenantId NoWelcome = $true } if ($CertificatePath) { try { $graphParams['Certificate'] = Get-METCertificateFromFile -Path $CertificatePath -Password $CertificatePassword } catch { $graphCertLoadError = $_.Exception.Message } } else { $graphParams['CertificateThumbprint'] = $CertificateThumbprint } } 'ManagedIdentity' { $graphParams = @{ Identity = $true; NoWelcome = $true } } } $mgContext = Get-MgContext -ErrorAction SilentlyContinue # Deliberately outside the try/catch below: a tenant mismatch must hard-stop, not # get silently downgraded to the same Write-Warning-and-continue path used for an # ordinary connection failure - that path exists so Graph's optional/degrading # design doesn't abort the whole session, but a wrong-tenant reuse is a correctness # bug, not an availability one, and should never be swallowed into a warning. # Checked whenever $requestedOrg is known (ServicePrincipal, or Interactive with # -DelegatedOrganization) rather than only for ServicePrincipal - a stale Graph # session left over from a botched Disconnect-METSession is just as much a # cross-customer leak risk in the Interactive+DelegatedOrganization/MSSP case. # Get-MgContext always returns a GUID (even when the caller passed a domain name), # so $requestedOrg is resolved to a GUID via Resolve-METTenantGuid before comparing # - a raw string compare against a domain name would mismatch on every call. if ($mgContext -and $requestedOrg) { $expectedTenantGuid = Resolve-METTenantGuid -TenantId $requestedOrg if (-not $expectedTenantGuid) { # Fail closed: an unresolvable tenant GUID (e.g. a transient OIDC discovery # outage) must not be treated as "no mismatch" - that would silently let a # stale Graph session from a different customer be reused unverified, exactly # the cross-customer leak this check exists to close. throw "Microsoft Graph is already connected to tenant '$($mgContext.TenantId)', but the requested tenant '$requestedOrg' could not be resolved to a GUID to verify they match (the OIDC discovery lookup failed - see -Verbose). Run Disconnect-METSession first, then reconnect, or pass -TenantId as a GUID instead of a domain name." } if ($mgContext.TenantId -ne $expectedTenantGuid) { throw "Microsoft Graph is already connected to tenant '$($mgContext.TenantId)', not the requested tenant '$requestedOrg' ($expectedTenantGuid). Run Disconnect-METSession first, then reconnect." } } if ($graphCertLoadError) { Write-Warning "Failed to connect to Microsoft Graph: $graphCertLoadError Group-membership expansion will fall back to Exchange Online cmdlets (reduced accuracy for Microsoft 365 Group references)." } else { try { if (-not $mgContext) { # A different MSAL version already loaded in-process (most often by # ExchangeOnlineManagement connecting first, per the comment at the top # of this function) cannot be reconciled by .NET at runtime. Detect it # up front so the warning names the real cause instead of surfacing # MSAL's opaque MissingMethodException/manifest-mismatch failure. $graphAuthModule = Get-Module -ListAvailable -Name Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1 $requiredMsalVersion = $null if ($graphAuthModule.ModuleBase) { $graphMsalPath = Join-Path $graphAuthModule.ModuleBase 'Dependencies' 'Core' 'Microsoft.Identity.Client.dll' $requiredMsalVersion = Get-METAssemblyFileVersion -Path $graphMsalPath } if ($requiredMsalVersion) { $conflict = Test-METAssemblyLoadConflict -AssemblyName 'Microsoft.Identity.Client' -RequiredVersion $requiredMsalVersion if ($conflict) { throw $conflict } } Write-Verbose 'Connecting to Microsoft Graph...' Connect-MgGraph @graphParams -ErrorAction Stop $servicesConnected.Add('Graph') } else { $servicesConnected.Add('Graph') Write-Verbose "Microsoft Graph already connected as $($mgContext.Account)." } } catch { Write-Warning "Failed to connect to Microsoft Graph: $($_.Exception.Message) Group-membership expansion will fall back to Exchange Online cmdlets (reduced accuracy for Microsoft 365 Group references). Retry with: Connect-METSession -CertificatePath <path> -CertificatePassword <securestring> for unattended use, or -DisableWAM for interactive use." } } } } if (-not $SkipTeams) { $teamsModule = Get-Module -ListAvailable -Name MicrosoftTeams | Where-Object { $_.Version -ge [version]'6.0.0' } | Select-Object -First 1 if (-not $teamsModule) { Write-Warning 'MicrosoftTeams 6.x or later is not installed. Teams checks will be skipped. Install with: Install-Module MicrosoftTeams -Scope CurrentUser' } else { $teamsImportFailed = $false try { # Inside its own try so an import failure (e.g. an MSAL assembly-load # conflict) degrades to a warning like every other Teams failure, # rather than aborting a session where EXO and Graph already connected. Import-Module MicrosoftTeams -ErrorAction Stop } catch { $teamsImportFailed = $true Write-Warning "Failed to connect to Microsoft Teams: $($_.Exception.Message) Teams checks will be skipped." } if (-not $teamsImportFailed) { # Get-CsTenant throws (not returns $null) when not connected, so probe inside try/catch. $teamsConnection = $null try { $teamsConnection = Get-CsTenant -ErrorAction Stop } catch { $teamsConnection = $null } # Deliberately outside the try/catch below: same reasoning as the Graph leg - # a tenant mismatch is a correctness bug and must hard-stop, not become a warning. # Checked whenever $requestedOrg is known (not just ServicePrincipal) and resolved # to a GUID via Resolve-METTenantGuid, since Get-CsTenant's TenantId is always a # GUID even when the caller passed a domain name - see the Graph leg above for why. if ($teamsConnection -and $requestedOrg) { $expectedTenantGuid = Resolve-METTenantGuid -TenantId $requestedOrg if (-not $expectedTenantGuid) { # Fail closed - see the identical Graph-leg comment above for why an # unresolvable GUID must not be treated as "no mismatch". throw "Microsoft Teams is already connected to tenant '$($teamsConnection.TenantId)', but the requested tenant '$requestedOrg' could not be resolved to a GUID to verify they match (the OIDC discovery lookup failed - see -Verbose). Run Disconnect-METSession first, then reconnect, or pass -TenantId as a GUID instead of a domain name." } if ($teamsConnection.TenantId -ne $expectedTenantGuid) { throw "Microsoft Teams is already connected to tenant '$($teamsConnection.TenantId)', not the requested tenant '$requestedOrg' ($expectedTenantGuid). Run Disconnect-METSession first, then reconnect." } } try { if (-not $teamsConnection) { Write-Verbose 'Connecting to Microsoft Teams...' $teamsParams = @{} switch ($PSCmdlet.ParameterSetName) { 'Interactive' { if ($UserPrincipalName) { $teamsParams['AccountId'] = $UserPrincipalName } if ($DelegatedOrganization) { # Connect-MicrosoftTeams's UserCredential set accepts -TenantId # (aliases Domain/TenantDomain) for the same CSP/GDAP scenario as # Graph above - previously silently ignored here too. $teamsParams['TenantId'] = $DelegatedOrganization } if ($UseDeviceAuthentication) { $teamsParams['UseDeviceAuthentication'] = $true } # WAM became the default in MicrosoftTeams 7.9.0 and P/Invokes # kernel32.dll, which does not exist off Windows. The switch is # documented as temporary, so only pass it if it is still present. $disableWamRequested = $DisableWAM -or -not $IsWindows if ($disableWamRequested -and (Get-Command Connect-MicrosoftTeams).Parameters.ContainsKey('DisableWAM')) { $teamsParams['DisableWAM'] = $true } } 'ServicePrincipal' { $teamsParams['ApplicationId'] = $AppId $teamsParams['TenantId'] = $TenantId $teamsParams['Certificate'] = if ($CertificatePath) { Get-METCertificateFromFile -Path $CertificatePath -Password $CertificatePassword } else { Get-METCertificateByThumbprint -Thumbprint $CertificateThumbprint } } 'ManagedIdentity' { $teamsParams['Identity'] = $true } } Connect-MicrosoftTeams @teamsParams $servicesConnected.Add('Teams') } else { $servicesConnected.Add('Teams') Write-Verbose "Microsoft Teams already connected to tenant $($teamsConnection.TenantId)." } } catch { $guidance = if ($IsWindows) { 'Teams checks will be skipped. Retry with: Connect-METSession -DisableWAM -Verbose' } else { 'Teams checks will be skipped. On a headless host with no reachable browser, retry with: Connect-METSession -UseDeviceAuthentication -Verbose' } if ($_.Exception -is [System.DllNotFoundException]) { $guidance = 'MicrosoftTeams 7.9.0+ defaults to WAM, which is Windows-only. ' + "Retry with: Connect-METSession -DisableWAM -Verbose (already applied automatically off-Windows; if it still fails and no browser is reachable at all, use -UseDeviceAuthentication)" } Write-Warning "Failed to connect to Microsoft Teams: $($_.Exception.Message) $guidance" } } } } $script:METConnection = @{ Mode = $requestedMode; Org = $requestedOrg } $script:METSessionInfo = [PSCustomObject]@{ AuthMode = $requestedMode DeviceCodeUsed = [bool]$UseDeviceAuthentication TenantIdentity = $requestedOrg ServicesConnected = $servicesConnected.ToArray() ConnectedAtUtc = [datetime]::UtcNow } Write-Verbose 'MET session ready.' } |