Private/BrowserAuth.ps1

function New-InTUIBrowserAuthCodeVerifier {
    [CmdletBinding()]
    param()

    $bytes = [byte[]]::new(32)
    [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
    return [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function New-InTUIBrowserAuthCodeChallenge {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Verifier
    )

    $hash = [System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($Verifier))
    return [Convert]::ToBase64String($hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function Get-InTUIBrowserAuthFreePort {
    [CmdletBinding()]
    param()

    $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
    $listener.Start()
    try {
        return $listener.LocalEndpoint.Port
    }
    finally {
        $listener.Stop()
    }
}

function ConvertTo-InTUIBrowserAuthScope {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Scopes,

        [Parameter(Mandatory)]
        [hashtable]$EnvironmentConfig
    )

    $scopeBase = $EnvironmentConfig.GraphAuthScopeBase
    if ([string]::IsNullOrWhiteSpace([string]$scopeBase)) {
        $scopeBase = ($EnvironmentConfig.GraphBaseUrl -replace '/v1\.0$', '' -replace '/beta$', '')
    }

    $passthroughScopes = @('openid', 'profile', 'offline_access', 'email')
    return @($Scopes | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | ForEach-Object {
        $scope = [string]$_
        if ($scope -match '^https://' -or $scope -in $passthroughScopes) {
            $scope
        }
        else {
            "$($scopeBase.TrimEnd('/'))/$scope"
        }
    } | Select-Object -Unique)
}

function Resolve-InTUIBrowserAuthHtml {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Success', 'Error')]
        [string]$Type,

        [Parameter()]
        [string]$SuccessPurpose = 'Authentication',

        [Parameter()]
        [string]$ErrorPurpose = 'Authentication'
    )

    $purpose = if ($Type -eq 'Success') { $SuccessPurpose } else { $ErrorPurpose }
    Assert-InTUIBrowserAuthPurpose -Purpose $purpose
    return Get-InTUIBrowserAuthHtml -Type $Type -Purpose $purpose
}

function Invoke-InTUIBrowserAuthTokenRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$EnvironmentConfig,

        [Parameter()]
        [string]$TenantId,

        [Parameter(Mandatory)]
        [hashtable]$Body
    )

    $authorityHost = if ([string]::IsNullOrWhiteSpace([string]$EnvironmentConfig.AuthorityHost)) {
        'https://login.microsoftonline.com'
    }
    else {
        [string]$EnvironmentConfig.AuthorityHost
    }
    $tenantSegment = if ([string]::IsNullOrWhiteSpace($TenantId)) { 'common' } else { $TenantId }
    $tokenUri = "$($authorityHost.TrimEnd('/'))/$tenantSegment/oauth2/v2.0/token"

    Invoke-RestMethod -Method POST -Uri $tokenUri -Body $Body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
}

function Send-InTUIBrowserAuthResponse {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Net.HttpListenerContext]$Context,

        [Parameter(Mandatory)]
        [string]$Html
    )

    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Html)
    $Context.Response.ContentType = 'text/html; charset=utf-8'
    $Context.Response.ContentLength64 = $bytes.Length
    $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
    $Context.Response.Close()
}

function Start-InTUIBrowserAuthRequest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri
    )

    Start-Process $Uri | Out-Null
}

function New-InTUIBrowserAuthAuthorizeUri {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$AuthorityHost,

        [Parameter(Mandatory)]
        [string]$TenantSegment,

        [Parameter(Mandatory)]
        [string]$ClientId,

        [Parameter(Mandatory)]
        [string]$RedirectUri,

        [Parameter(Mandatory)]
        [string[]]$Scopes,

        [Parameter(Mandatory)]
        [string]$State,

        [Parameter(Mandatory)]
        [string]$CodeChallenge,

        [Parameter()]
        [string]$Claims
    )

    $authParams = [ordered]@{
        client_id             = $ClientId
        response_type         = 'code'
        redirect_uri          = $RedirectUri
        response_mode         = 'query'
        scope                 = ($Scopes -join ' ')
        state                 = $State
        code_challenge        = $CodeChallenge
        code_challenge_method = 'S256'
        prompt                = 'select_account'
    }
    if (-not [string]::IsNullOrWhiteSpace($Claims)) {
        $authParams['claims'] = $Claims
    }

    $query = ($authParams.GetEnumerator() | ForEach-Object {
        "$($_.Key)=$([uri]::EscapeDataString([string]$_.Value))"
    }) -join '&'
    return "$($AuthorityHost.TrimEnd('/'))/$TenantSegment/oauth2/v2.0/authorize?$query"
}

function Get-InTUIBrowserAccessToken {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Scopes,

        [Parameter(Mandatory)]
        [hashtable]$EnvironmentConfig,

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string]$ClientId,

        [Parameter()]
        [string]$Claims,

        [Parameter()]
        [string]$SuccessPurpose = 'Authentication',

        [Parameter()]
        [string]$ErrorPurpose = 'Authentication'
    )

    $resolvedClientId = if (-not [string]::IsNullOrWhiteSpace($ClientId)) {
        $ClientId
    }
    else {
        '14d82eec-204b-4c2f-b7e8-296a70dab67e'
    }

    $resolvedScopes = ConvertTo-InTUIBrowserAuthScope -Scopes $Scopes -EnvironmentConfig $EnvironmentConfig
    $successHtml = Resolve-InTUIBrowserAuthHtml -Type Success -SuccessPurpose $SuccessPurpose -ErrorPurpose $ErrorPurpose
    $errorHtml = Resolve-InTUIBrowserAuthHtml -Type Error -SuccessPurpose $SuccessPurpose -ErrorPurpose $ErrorPurpose
    $authorityHost = if ([string]::IsNullOrWhiteSpace([string]$EnvironmentConfig.AuthorityHost)) {
        'https://login.microsoftonline.com'
    }
    else {
        [string]$EnvironmentConfig.AuthorityHost
    }
    $tenantSegment = if ([string]::IsNullOrWhiteSpace($TenantId)) { 'common' } else { $TenantId }
    $redirectPort = Get-InTUIBrowserAuthFreePort
    $redirectUri = "http://localhost:$redirectPort/"
    $verifier = New-InTUIBrowserAuthCodeVerifier
    $challenge = New-InTUIBrowserAuthCodeChallenge -Verifier $verifier
    $state = [guid]::NewGuid().ToString('N')

    $authUri = New-InTUIBrowserAuthAuthorizeUri `
        -AuthorityHost $authorityHost `
        -TenantSegment $tenantSegment `
        -ClientId $resolvedClientId `
        -RedirectUri $redirectUri `
        -Scopes $resolvedScopes `
        -State $state `
        -CodeChallenge $challenge `
        -Claims $Claims

    $listener = [System.Net.HttpListener]::new()
    $listener.Prefixes.Add($redirectUri)
    $listener.Start()

    try {
        Start-InTUIBrowserAuthRequest -Uri $authUri

        $contextTask = $listener.GetContextAsync()
        if (-not $contextTask.Wait([timespan]::FromMinutes(5))) {
            throw 'Authentication timed out after 5 minutes.'
        }

        $context = $contextTask.Result
        $responseSent = $false
        try {
            $code = $context.Request.QueryString['code']
            $authError = $context.Request.QueryString['error']
            $errorDescription = $context.Request.QueryString['error_description']
            $returnedState = $context.Request.QueryString['state']

            if ($returnedState -ne $state) {
                throw 'OAuth state mismatch. Authentication was cancelled for safety.'
            }
            if ([string]::IsNullOrWhiteSpace($code)) {
                throw "Authorization failed: $authError - $errorDescription"
            }

            $body = @{
                client_id     = $resolvedClientId
                grant_type    = 'authorization_code'
                code          = $code
                redirect_uri  = $redirectUri
                code_verifier = $verifier
                scope         = ($resolvedScopes -join ' ')
            }
            $tokens = Invoke-InTUIBrowserAuthTokenRequest -EnvironmentConfig $EnvironmentConfig -TenantId $TenantId -Body $body
            if ([string]::IsNullOrWhiteSpace([string]$tokens.access_token)) {
                throw 'Browser authentication did not return an access token.'
            }

            Send-InTUIBrowserAuthResponse -Context $context -Html $successHtml
            $responseSent = $true
            return [string]$tokens.access_token
        }
        catch {
            if (-not $responseSent) {
                Send-InTUIBrowserAuthResponse -Context $context -Html $errorHtml
            }
            throw
        }
    }
    finally {
        if ($listener.IsListening) {
            $listener.Stop()
        }
        $listener.Close()
    }
}

function Connect-InTUIBrowserGraph {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'Microsoft.Graph.Authentication requires a SecureString access token for Connect-MgGraph -AccessToken.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string[]]$Scopes,

        [Parameter(Mandatory)]
        [hashtable]$EnvironmentConfig,

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [string]$ClientId,

        [Parameter()]
        [string]$SuccessPurpose = 'Authentication',

        [Parameter()]
        [string]$ErrorPurpose = 'Authentication'
    )

    $accessToken = Get-InTUIBrowserAccessToken -Scopes $Scopes -EnvironmentConfig $EnvironmentConfig -TenantId $TenantId -ClientId $ClientId -SuccessPurpose $SuccessPurpose -ErrorPurpose $ErrorPurpose
    if ([string]::IsNullOrWhiteSpace($accessToken)) {
        throw 'Browser authentication did not return an access token.'
    }

    $secureToken = ConvertTo-SecureString -String $accessToken -AsPlainText -Force
    Connect-MgGraph -AccessToken $secureToken -NoWelcome:$true -Environment $EnvironmentConfig.MgEnvironment
}