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