public/Connect-MsecSharePointOnline.ps1
|
function Connect-MsecSharePointOnline { <# .SYNOPSIS Signs the PnP.PowerShell module in using the msec session's token, so Get-PnP* commands run as the msec app without its private key leaving Key Vault. .DESCRIPTION Much of SharePoint IS reachable through Graph - /sites, site permissions, the Microsoft 365 group behind a group-connected site - and where that is true, use Graph. What Graph does NOT expose is a site's own SharePoint groups: the Owners, Members and Visitors groups that Get-PnPGroup reads. Those are a SharePoint concept, they are the actual permission model for classic (STS#3) sites, and there is no Graph equivalent. PnP is the only way to read them. THE TOKEN IS PER SITE COLLECTION HOST, NOT TENANT-WIDE. SharePoint issues tokens whose audience is the hostname - https://contoso.sharepoint.com - so the resource is derived from -Url rather than fixed. A token for the tenant host does not work against contoso-admin.sharepoint.com, which is why connecting to the admin centre needs its own call. SharePoint needs Sites.Read.All (or Sites.FullControl.All to write) as an APPLICATION permission, admin-consented. Unlike Exchange, no directory role is involved. THE MODULE IS NOT AN msec DEPENDENCY. It is imported only when this command is called. .PARAMETER Url The site collection to connect to, e.g. https://contoso.sharepoint.com/sites/finance, or the tenant root. The token's audience is derived from this URL's host. .PARAMETER MinimumMinutes Fail unless the token has at least this long left. Default 5. PnP is handed a static token and cannot renew it. .PARAMETER PassThru Return the connection object instead of making it the ambient one, so the caller's current site is left alone. Pass the result to any PnP cmdlet's -Connection parameter. This is how Get-MsecSharePointSiteUser connects on your behalf without moving your session out from under you. .EXAMPLE Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid> Connect-MsecSharePointOnline -Url https://contoso.sharepoint.com Get-PnPTenantSite .EXAMPLE # Enumerating many sites: one connection per site collection, which is how PnP works. Connect-MsecSharePointOnline -Url https://contoso.sharepoint.com Get-PnPTenantSite | ForEach-Object { Connect-MsecSharePointOnline -Url $_.Url Get-PnPGroup -AssociatedOwnerGroup } .NOTES Needs Connect-Msec first, and the PnP.PowerShell module - which is NOT a dependency of msec. THERE IS NO -AsCurrentUser HERE, unlike Connect-MsecTeams, and it is not an oversight. Borrowing the Az session works for Teams because that API accepts a token whose audience is the service. SharePoint does not: it validates the audience against the HOST it is presented to, and Get-AzAccessToken -ResourceUrl normalises every sharepoint.com URL - tenant root and admin host alike - to the service principal's GUID, 00000003-0000-0ff1-ce00-000000000000. The token is issued, carries user_impersonation, and is then refused by every site with a bare 401 and an empty content type. Verified against a live tenant on both hosts. So for a delegated SharePoint session, either use the SharePoint admin centre, or register a PUBLIC CLIENT app with delegated SharePoint permissions and a redirect URI and use PnP's own browser flow: Connect-PnPOnline -Url <site> -Interactive -ClientId <appid>. That flow does not touch the Windows broker, so it works on macOS and Linux, where Connect-MicrosoftTeams's interactive sign-in does not. The msec app cannot be used for it - it holds APPLICATION permissions and has no redirect URI. Tenant-level cmdlets (Get-PnPTenantSite, Set-PnPTenant) require the ADMIN host - contoso-admin.sharepoint.com - not the tenant root. PnP will say so if you connect to the wrong one, but the message is easy to misread as a permission failure. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Url, [ValidateRange(0, 60)] [int] $MinimumMinutes = 5, # Return the connection instead of making it the ambient one. PnP keeps a single global # connection, so anything that connects silently moves the caller's session to a # different site - which is fine when a human types it and wrong when a command does it # on the caller's behalf. Commands use this; people usually do not. [switch] $PassThru ) Assert-MsecSession if (-not (Get-Module -ListAvailable -Name PnP.PowerShell)) { throw 'PnP.PowerShell is required for Connect-MsecSharePointOnline. Install with: Install-Module PnP.PowerShell -Scope CurrentUser' } Import-Module PnP.PowerShell -ErrorAction Stop # The audience is the HOST, not the full URL - see the note above. Parsed rather than # string-trimmed so a path, a port or a trailing slash cannot corrupt it. $uri = $null if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref] $uri)) { throw "'$Url' is not an absolute URL. Pass something like https://contoso.sharepoint.com/sites/finance." } $resource = "$($uri.Scheme)://$($uri.Host)" $token = Get-MsecAccessToken -Resource $resource $expiry = $script:MsecSession.Tokens[$resource].ExpiresOn if ($expiry) { $left = [int] ($expiry - [DateTimeOffset]::UtcNow).TotalMinutes if ($left -lt $MinimumMinutes) { throw "The SharePoint token for $resource has $left minute(s) left, which is under the $MinimumMinutes requested. PnP is handed a static token and cannot renew it. Run Connect-Msec again for a fresh token." } Write-Verbose "SharePoint token for $resource valid for about $left more minute(s)." } # -AccessToken is a plain String here, as it is for Exchange - Connect-MgGraph is the odd # one out in wanting a SecureString. if ($PassThru) { # -ReturnConnection hands the connection back WITHOUT touching the ambient one, so a # caller who was working against another site stays there. $connection = Connect-PnPOnline -Url $Url -AccessToken $token -ReturnConnection -ErrorAction Stop Write-Verbose "SharePoint connection created for $Url as app $($script:MsecSession.ClientId) (app-only, not ambient)." return $connection } Connect-PnPOnline -Url $Url -AccessToken $token -ErrorAction Stop Write-Verbose "SharePoint connected to $Url as app $($script:MsecSession.ClientId) (app-only)." } |