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 Interactive
        Sign in as yourself through PnP's browser flow rather than with the app certificate.
        Required to reach the tenant admin host, where the app-only path is refused - see the
        description. Needs -ClientId.

    .PARAMETER ClientId
        The Entra app to sign in with, for -Interactive. PnP 3.x ships no default app, so this
        is not optional. It must be registered in this tenant and hold the DELEGATED SharePoint
        permission AllSites.FullControl.

    .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.

        THE Az SESSION CANNOT BE BORROWED HERE, unlike Connect-MsecTeams. That API accepts a
        token whose audience is the service; SharePoint 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 with a bare 401. Verified against a live tenant on both hosts, and
        re-verified since.

        -Interactive IS THE WAY IN AS YOURSELF, because PnP does its own sign-in and can
        therefore mint a token whose audience is the actual host. That is what makes the
        TENANT ADMIN host reachable - the app-only certificate path authenticates against it
        and then fails every admin call with "Attempted to perform an unauthorized operation",
        because tenant administration needs Sites.FullControl.All, a WRITE permission this
        module deliberately does not hold. Signed in as yourself, the capability comes from
        your own SharePoint Administrator role instead, and the app gains nothing: the same
        bargain Connect-MsecAdmin makes.

        -Interactive NEEDS A ClientId, and PnP 3.x no longer ships a default app - it fails
        with "Please specify a valid client id for an Entra ID App Registration". Use an app
        registered in your own tenant with the DELEGATED SharePoint permission
        AllSites.FullControl; a leftover service principal for a retired multi-tenant app
        fails at sign-in with AADSTS700016 even though it still lists in the directory.

        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,

        # SIGN IN AS YOURSELF through PnP's own browser flow instead of using the app
        # certificate. The only way to reach the tenant admin host - see the description.
        [switch] $Interactive,

        # Required by -Interactive. An app registered in THIS tenant holding the delegated
        # SharePoint permission AllSites.FullControl.
        [string] $ClientId
    )

    if ($Interactive -and -not $ClientId) {
        throw ('-Interactive needs -ClientId. PnP 3.x ships no default app and fails with ' +
               '"Please specify a valid client id for an Entra ID App Registration". Use an app ' +
               'registered in this tenant holding the delegated SharePoint permission AllSites.FullControl.')
    }
    if ($ClientId -and -not $Interactive) {
        throw '-ClientId only applies to -Interactive. The app-only path authenticates with the Key Vault certificate instead.'
    }

    # Interactive does NOT need the app session: it signs in as the human and the capability
    # comes from their own roles, not from the msec app.
    if (-not $Interactive) { 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)"

    if ($Interactive) {
        # PnP mints its own token, so the audience matches the host and the admin host is
        # reachable. A cached sign-in is reused silently; otherwise a browser opens.
        $connectArgs = @{ Url = $Url; Interactive = $true; ClientId = $ClientId; ErrorAction = 'Stop' }
        if ($PassThru) {
            $connection = Connect-PnPOnline @connectArgs -ReturnConnection
            Write-Verbose "SharePoint connection created for $Url interactively (not ambient)."
            return $connection
        }
        Connect-PnPOnline @connectArgs
        Write-Verbose "SharePoint connected to $Url interactively, as the signed-in user."
        return
    }

    $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)."
}