Private/Get-AzureDevOpsTenantId.ps1

function Get-AzureDevOpsTenantId {
    <#
    .SYNOPSIS
    Discovers the Entra tenant that backs an Azure DevOps collection, or $null.
 
    .DESCRIPTION
    Azure DevOps returns the tenant GUID in the 'X-VSS-ResourceTenant' response header,
    so no credential is needed to find it - but only on a response that actually
    challenges for authentication. Two probes are tried in order:
 
      1. connectionData, unauthenticated. Cheap, and enough on collections that answer
         it with the header.
      2. An endpoint that REQUIRES authentication (projects), with redirects not
         followed. Anonymous connectionData answers 203 with no tenant header at all on
         dev.azure.com today, so a collection is only proved non-Entra by the second
         probe coming back empty as well.
 
    Probe 1 alone reported EVERY organisation as non-Entra-backed - including ones that
    plainly are - which silently downgraded ambient Entra auth to the stored PAT
    everywhere. Hence the second probe: a false 'no tenant' is not a visible failure,
    it is a quiet fallback that only shows up as a missing-PAT error much later.
 
    Returns $null - never throws - when the collection is unreachable, or is an
    on-premises Azure DevOps Server collection with no Entra tenant behind it. The caller
    decides what that means; for Get-EntraAccessToken it means "use another auth method".
 
    Results are cached per collection for the session: this runs before every REST call
    that has not already resolved its auth.
 
    .PARAMETER Collection
    Collection or organisation URL.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Collection
    )

    $key = $Collection.TrimEnd('/').ToLowerInvariant()
    if (-not $script:TenantIdCache) { $script:TenantIdCache = @{} }
    if ($script:TenantIdCache.ContainsKey($key)) { return $script:TenantIdCache[$key] }

    $base = $Collection.TrimEnd('/')

    # The header can be an array and/or comma-separated, and an all-zero GUID means
    # 'no tenant' rather than a tenant - take the first real GUID, if any.
    $readTenant = {
        param($Response)
        if (-not $Response) { return $null }
        $raw = $null
        try { $raw = $Response.Headers['X-VSS-ResourceTenant'] } catch { return $null }
        if (-not $raw) { return $null }
        (($raw -join ',') -split ',' |
            ForEach-Object { $_.Trim() } |
            Where-Object { $_ -as [guid] -and $_ -ne [guid]::Empty } |
            Select-Object -First 1)
    }

    # ErrorAction is deliberately NOT 'Stop' on either probe. Refusing to follow the
    # sign-in redirect makes Invoke-WebRequest report an error, and under -ErrorAction
    # Stop that surfaces as an InvalidOperationException carrying NO .Response - the
    # headers are then unreachable and every collection looks non-Entra. Left
    # non-terminating, the 302 is returned normally and its headers can be read.
    $probes = @(
        # Anonymous connectionData.
        { Invoke-WebRequest -Uri ('{0}/_apis/connectionData?api-version=7.1-preview' -f $base) `
                -Method Get -SkipHttpErrorCheck -ErrorAction SilentlyContinue },
        # Authentication challenge: the sign-in redirect (or 401) names the tenant.
        # Redirects are NOT followed - following one lands on a login page that no
        # longer carries the header.
        { Invoke-WebRequest -Uri ('{0}/_apis/projects?api-version=7.1' -f $base) `
                -Method Get -MaximumRedirection 0 -SkipHttpErrorCheck -ErrorAction SilentlyContinue }
    )

    $tenantId = $null
    foreach ($probe in $probes) {
        try { $tenantId = & $readTenant (& $probe) }
        catch { Write-Verbose "Tenant discovery probe failed for '$Collection': $($_.Exception.Message)" }
        if ($tenantId) { break }
    }

    $script:TenantIdCache[$key] = $tenantId
    return $tenantId
}