diff --git a/Shared/AzureFunctions/Get-GraphAccessToken.ps1 b/Shared/AzureFunctions/Get-GraphAccessToken.ps1 index a0b9bd7949..67893a435d 100644 --- a/Shared/AzureFunctions/Get-GraphAccessToken.ps1 +++ b/Shared/AzureFunctions/Get-GraphAccessToken.ps1 @@ -7,11 +7,9 @@ . $PSScriptRoot\..\ScriptUpdateFunctions\Invoke-WebRequestWithProxyDetection.ps1 <# - This function is used to get an access token for the Azure Graph API by using the OAuth 2.0 authorization code flow - with PKCE (Proof Key for Code Exchange). The OAuth 2.0 authorization code grant type, or auth code flow, - enables a client application to obtain authorized access to protected resources like web APIs. - The auth code flow requires a user-agent that supports redirection from the authorization server - (the Microsoft identity platform) back to your application. + This function is used to get an access token for the Microsoft Graph API. By default, it uses the OAuth 2.0 authorization + code flow with PKCE (Proof Key for Code Exchange). If a local browser callback can't be completed, it falls back to + the OAuth 2.0 device code flow so the user can authenticate from another device. More information about the auth code flow with PKCE can be found here: https://learn.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow#protocol-details @@ -29,12 +27,220 @@ function Get-GraphAccessToken { [string]$ClientId = "1950a258-227b-4e31-a9cf-717495945fc2", # Well-known Microsoft Azure PowerShell application ID [Parameter(Mandatory = $false)] - [string]$Scope = "$($GraphApiUrl)//AuditLog.Read.All Directory.AccessAsUser.All email openid profile" + [string]$Scope = "$($GraphApiUrl)//AuditLog.Read.All Directory.AccessAsUser.All email openid profile", + + [Parameter(Mandatory = $false)] + [ValidateSet("Auto", "AuthorizationCode", "DeviceCode")] + [string]$AuthFlow = "Auto" ) begin { Write-Verbose "Calling $($MyInvocation.MyCommand)" + function ConvertTo-GraphAccessTokenResult { + param( + [Parameter(Mandatory = $true)] + [psobject]$TokenResponse + ) + + $tenantId = $null + $idToken = if ($null -ne $TokenResponse.PSObject.Properties["id_token"]) { + $TokenResponse.id_token + } else { + $null + } + $accessToken = if ($null -ne $TokenResponse.PSObject.Properties["access_token"]) { + $TokenResponse.access_token + } else { + $null + } + + if (-not [System.String]::IsNullOrEmpty($idToken)) { + $idTokenPayload = (Convert-JsonWebTokenToObject $idToken).Payload + $tenantId = $idTokenPayload.tid + } + + if ([System.String]::IsNullOrEmpty($tenantId) -and + -not [System.String]::IsNullOrEmpty($accessToken)) { + $accessTokenPayload = (Convert-JsonWebTokenToObject $accessToken).Payload + $tenantId = $accessTokenPayload.tid + } + + return [PSCustomObject]@{ + AccessToken = $accessToken + TenantId = $tenantId + } + } + + function Invoke-OAuthRestMethod { + param( + [Parameter(Mandatory = $true)] + [string]$Uri, + + [Parameter(Mandatory = $true)] + [hashtable]$Body + ) + + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $requestParams = @{ + Uri = $Uri + Method = "POST" + ContentType = "application/x-www-form-urlencoded" + Body = $Body + UseBasicParsing = $true + ErrorAction = "Stop" + } + + try { + $proxyUri = ([System.Net.WebRequest]::GetSystemWebProxy()).GetProxy($Uri) + if ($Uri -ne $proxyUri.OriginalString) { + Write-Verbose "Proxy server configuration detected" + Write-Verbose $proxyUri.OriginalString + $requestParams.Proxy = $proxyUri.OriginalString + $requestParams.ProxyUseDefaultCredentials = $true + } + } catch { + Write-Verbose "Unable to check for proxy server configuration" + } + + try { + return [PSCustomObject]@{ + StatusCode = 200 + Content = (Invoke-RestMethod @requestParams) + } + } catch { + $statusCode = $null + $content = $null + $rawContent = $null + + if (-not [System.String]::IsNullOrEmpty($_.ErrorDetails.Message)) { + $rawContent = $_.ErrorDetails.Message + } + + if ($null -ne $_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + + if ([System.String]::IsNullOrEmpty($rawContent) -and + $_.Exception.Response -is [System.Net.Http.HttpResponseMessage]) { + $rawContent = $_.Exception.Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } elseif ([System.String]::IsNullOrEmpty($rawContent)) { + $stream = $_.Exception.Response.GetResponseStream() + + if ($null -ne $stream) { + $reader = New-Object System.IO.StreamReader($stream) + + try { + $rawContent = $reader.ReadToEnd() + } finally { + $reader.Dispose() + $stream.Dispose() + } + } + } + } + + if (-not [System.String]::IsNullOrEmpty($rawContent)) { + try { + $content = $rawContent | ConvertFrom-Json + } catch { + $content = $rawContent + } + } + + return [PSCustomObject]@{ + StatusCode = $statusCode + Content = $content + } + } + } + + function Get-OAuthErrorMessage { + param( + [Parameter(Mandatory = $true)] + [string]$DefaultMessage, + + [Parameter(Mandatory = $false)] + [object]$ErrorContent, + + [Parameter(Mandatory = $false)] + [Nullable[int]]$StatusCode + ) + + $errorMessageParts = New-Object System.Collections.Generic.List[string] + $errorMessageParts.Add($DefaultMessage) | Out-Null + + if ($null -ne $StatusCode) { + $errorMessageParts.Add("Status code: $StatusCode") | Out-Null + } + + if ($ErrorContent -is [string]) { + if (-not [System.String]::IsNullOrEmpty($ErrorContent)) { + $errorMessageParts.Add($ErrorContent) | Out-Null + } + } elseif ($null -ne $ErrorContent) { + $oauthError = $ErrorContent.error + $oauthErrorDescription = $ErrorContent.error_description + + if (-not [System.String]::IsNullOrEmpty($oauthError)) { + $errorMessageParts.Add("Error: $oauthError") | Out-Null + } + + if (-not [System.String]::IsNullOrEmpty($oauthErrorDescription)) { + $errorMessageParts.Add("Error description: $oauthErrorDescription") | Out-Null + } + } + + return $errorMessageParts -join " " + } + + function Test-AuthorizationCodeFlowAvailable { + if ($null -ne (Get-Variable -Name PSSenderInfo -ErrorAction SilentlyContinue)) { + Write-Verbose "Remote PowerShell session detected. Device code authentication will be used." + return $false + } + + if ($Host.Name -eq "ServerRemoteHost") { + Write-Verbose "Remote PowerShell host detected. Device code authentication will be used." + return $false + } + + if (-not [System.Environment]::UserInteractive) { + Write-Verbose "Non-interactive PowerShell session detected. Device code authentication will be used." + return $false + } + + try { + $currentVersionPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" + $installationType = (Get-ItemProperty -Path $currentVersionPath -Name InstallationType -ErrorAction Stop).InstallationType + if ($installationType -eq "Server Core") { + Write-Verbose "Server Core installation detected. Device code authentication will be used." + return $false + } + } catch { + Write-Verbose "Unable to determine Windows installation type." + } + + return $true + } + + function ConvertTo-OAuthQueryString { + param( + [Parameter(Mandatory = $true)] + [System.Collections.IDictionary]$Parameters + ) + + $queryParameters = New-Object System.Collections.Generic.List[string] + + foreach ($parameter in $Parameters.GetEnumerator()) { + $queryParameters.Add(("{0}={1}" -f + [System.Uri]::EscapeDataString([string]$parameter.Key), + [System.Uri]::EscapeDataString([string]$parameter.Value))) | Out-Null + } + + return $queryParameters -join "&" + } + <# This helper function takes a query string (such as the one returned in an OAuth 2.0 redirect URI) and converts it into a PowerShell hashtable for easier access to individual parameters. @@ -102,30 +308,166 @@ function Get-GraphAccessToken { return $map } - $responseType = "code" # Provides the code as a query string parameter on our redirect URI - $prompt = "select_account" # We want to show the select account dialog - $redirectUri = "http://localhost:8004" # This is the default port for the local listener - $codeChallengeMethod = "S256" # The code challenge method is S256 (SHA256) - $codeChallengeVerifier = Get-NewS256CodeChallengeVerifier - $state = ([guid]::NewGuid()).Guid # State which is needed for CSRF protection - $nonce = ([guid]::NewGuid()).Guid # Nonce to prevent replay attacks - $connectionSuccessful = $false - } - process { - $codeChallenge = $codeChallengeVerifier.CodeChallenge - $codeVerifier = $codeChallengeVerifier.Verifier + function Invoke-DeviceCodeFlow { + param( + [Parameter(Mandatory = $true)] + [string]$AzureADEndpoint, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [string]$Scope + ) + + $deviceCodeResponse = Invoke-OAuthRestMethod -Uri "$AzureADEndpoint/organizations/oauth2/v2.0/devicecode" -Body @{ + client_id = $ClientId + scope = $Scope + } + + if ($deviceCodeResponse.StatusCode -ne 200 -or $null -eq $deviceCodeResponse.Content) { + Write-Host (Get-OAuthErrorMessage ` + -DefaultMessage "Unable to initiate device code authentication." ` + -ErrorContent $deviceCodeResponse.Content ` + -StatusCode $deviceCodeResponse.StatusCode) -ForegroundColor Red + return $null + } + + $deviceCodeInfo = $deviceCodeResponse.Content + $verificationUri = if (-not [System.String]::IsNullOrEmpty($deviceCodeInfo.verification_uri)) { + $deviceCodeInfo.verification_uri + } else { + $deviceCodeInfo.verification_url + } + + if (-not [System.String]::IsNullOrEmpty($deviceCodeInfo.message)) { + Write-Host $deviceCodeInfo.message -ForegroundColor Yellow + } else { + Write-Host ("To sign in, use a browser on any device to open '{0}' and enter code '{1}'." -f $verificationUri, $deviceCodeInfo.user_code) -ForegroundColor Yellow + } + + $tokens = $null + $pollingInterval = if ($deviceCodeInfo.interval) { [int]$deviceCodeInfo.interval } else { 5 } + $pollDeadline = (Get-Date).AddSeconds([int]$deviceCodeInfo.expires_in) + + while ((Get-Date) -lt $pollDeadline) { + Start-Sleep -Seconds $pollingInterval + + $pollResponse = Invoke-OAuthRestMethod -Uri "$AzureADEndpoint/organizations/oauth2/v2.0/token" -Body @{ + grant_type = "urn:ietf:params:oauth:grant-type:device_code" + client_id = $ClientId + device_code = $deviceCodeInfo.device_code + } + + if ($pollResponse.StatusCode -eq 200 -and $null -ne $pollResponse.Content) { + $tokens = $pollResponse.Content + break + } + + $oauthError = $pollResponse.Content.error + + if ($oauthError -eq "authorization_pending") { + continue + } + + if ($oauthError -eq "slow_down") { + $pollingInterval += 5 + continue + } + + if ($oauthError -eq "expired_token") { + Write-Host "Device code authentication expired before sign-in completed." -ForegroundColor Red + return $null + } + + if ($oauthError -eq "authorization_declined") { + Write-Host "Device code authentication was declined." -ForegroundColor Red + return $null + } - # Request an authorization code from the Microsoft Azure Active Directory endpoint - $authCodeRequestUrl = "$AzureADEndpoint/organizations/oauth2/v2.0/authorize?client_id=$ClientId" + - "&response_type=$responseType&redirect_uri=$redirectUri&scope=$scope&state=$state&nonce=$nonce&prompt=$prompt" + - "&code_challenge_method=$codeChallengeMethod&code_challenge=$codeChallenge" + $errorDescription = $pollResponse.Content.error_description - Start-Process -FilePath $authCodeRequestUrl - $authCodeResponse = Start-LocalListener -TimeoutSeconds 120 + if ([System.String]::IsNullOrEmpty($errorDescription)) { + $errorDescription = if (-not [System.String]::IsNullOrEmpty($oauthError)) { + $oauthError + } else { + "No error details were returned by the token endpoint." + } + } + + Write-Host ("Device code authentication failed: {0}" -f $errorDescription) -ForegroundColor Red + return $null + } + + if ($null -eq $tokens) { + Write-Host "Device code authentication timed out before sign-in completed." -ForegroundColor Red + return $null + } + + return ConvertTo-GraphAccessTokenResult -TokenResponse $tokens + } + + function Invoke-AuthorizationCodeFlow { + param( + [Parameter(Mandatory = $true)] + [string]$AzureADEndpoint, + + [Parameter(Mandatory = $true)] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [string]$Scope + ) + + $responseType = "code" # Provides the code as a query string parameter on our redirect URI + $prompt = "select_account" # We want to show the select account dialog + $redirectUri = "http://localhost:8004" # This is the default port for the local listener + $codeChallengeMethod = "S256" # The code challenge method is S256 (SHA256) + $codeChallengeVerifier = Get-NewS256CodeChallengeVerifier + $state = ([guid]::NewGuid()).Guid # State which is needed for CSRF protection + $nonce = ([guid]::NewGuid()).Guid # Nonce to prevent replay attacks + + $authorizationCodeResult = [PSCustomObject]@{ + GraphAccessToken = $null + AuthorizationCodeFlowUnavailable = $false + AuthorizationCodeFailureMessage = $null + } + + $authorizeQueryString = ConvertTo-OAuthQueryString -Parameters ([ordered]@{ + client_id = $ClientId + response_type = $responseType + redirect_uri = $redirectUri + scope = $Scope + state = $state + nonce = $nonce + prompt = $prompt + code_challenge_method = $codeChallengeMethod + code_challenge = $codeChallengeVerifier.CodeChallenge + }) + + # Request an authorization code from the Microsoft Azure Active Directory endpoint + $authCodeRequestUrl = "{0}/organizations/oauth2/v2.0/authorize?{1}" -f $AzureADEndpoint, $authorizeQueryString + + try { + Start-Process -FilePath $authCodeRequestUrl -ErrorAction Stop + $authCodeResponse = Start-LocalListener -TimeoutSeconds 120 + } catch { + Write-Verbose "Unable to launch a browser for authorization code authentication: $($_.Exception.Message)" + $authorizationCodeResult.AuthorizationCodeFlowUnavailable = $true + $authorizationCodeResult.AuthorizationCodeFailureMessage = "Unable to launch a browser for authorization code authentication." + + return $authorizationCodeResult + } + + if ($null -eq $authCodeResponse) { + $authorizationCodeResult.AuthorizationCodeFlowUnavailable = $true + $authorizationCodeResult.AuthorizationCodeFailureMessage = "Unable to acquire an authorization code from the Microsoft Azure Active Directory endpoint." - if ($null -ne $authCodeResponse) { - # Parse the authCodeResponse to get the state that was returned - # We need the state to add CSRF and mix-up defense protection + return $authorizationCodeResult + } + + # Parse the authCodeResponse to get the state that was returned. + # We need the state to add CSRF and mix-up defense protection. $queryString = ConvertFrom-QueryString -Query $authCodeResponse $returnedState = $queryString["state"] @@ -133,7 +475,7 @@ function Get-GraphAccessToken { if (-not $returnedState) { Write-Host "No state value was returned" -ForegroundColor Red - return + return $authorizationCodeResult } Write-Verbose "Script state: '$state' - Returned state: '$returnedState'" @@ -141,33 +483,38 @@ function Get-GraphAccessToken { if ($returnedState -cne $state) { Write-Host "State mismatch detected! Expected '$state', got '$returnedState'" -ForegroundColor Red - return + return $authorizationCodeResult } $code = $queryString["code"] if (-not $code) { - Write-Host "Authorization code is missing in callback" -ForegroundColor Red + $authorizationCodeResult.AuthorizationCodeFailureMessage = Get-OAuthErrorMessage ` + -DefaultMessage "Authorization code is missing in callback." ` + -ErrorContent ([PSCustomObject]@{ + error = $queryString["error"] + error_description = $queryString["error_description"] + }) + + Write-Host $authorizationCodeResult.AuthorizationCodeFailureMessage -ForegroundColor Red - return + return $authorizationCodeResult } - # Redeem the returned code for an access token - $redeemAuthCodeParams = @{ + $redeemAuthCodeResponse = Invoke-WebRequestWithProxyDetection -ParametersObject @{ Uri = "$AzureADEndpoint/organizations/oauth2/v2.0/token" Method = "POST" ContentType = "application/x-www-form-urlencoded" Body = @{ client_id = $ClientId - scope = $scope + scope = $Scope code = $code redirect_uri = $redirectUri grant_type = "authorization_code" - code_verifier = $codeVerifier + code_verifier = $codeChallengeVerifier.Verifier } UseBasicParsing = $true } - $redeemAuthCodeResponse = Invoke-WebRequestWithProxyDetection -ParametersObject $redeemAuthCodeParams if ($redeemAuthCodeResponse.StatusCode -eq 200) { $tokens = $redeemAuthCodeResponse.Content | ConvertFrom-Json @@ -178,23 +525,51 @@ function Get-GraphAccessToken { if ($idTokenPayload.nonce -cne $nonce) { Write-Host "Nonce mismatch detected" -ForegroundColor Red - return + return $authorizationCodeResult } - $connectionSuccessful = $true + $authorizationCodeResult.GraphAccessToken = ConvertTo-GraphAccessTokenResult -TokenResponse $tokens } else { Write-Host "Unable to redeem the authorization code for an access token." -ForegroundColor Red } + + return $authorizationCodeResult + } + + $graphAccessToken = $null + } + process { + $effectiveAuthFlow = $AuthFlow + + if ($effectiveAuthFlow -eq "Auto" -and -not (Test-AuthorizationCodeFlowAvailable)) { + $effectiveAuthFlow = "DeviceCode" + } + + $authFlowParams = @{ + AzureADEndpoint = $AzureADEndpoint + ClientId = $ClientId + Scope = $Scope + } + + if ($effectiveAuthFlow -eq "DeviceCode") { + $graphAccessToken = Invoke-DeviceCodeFlow @authFlowParams } else { - Write-Host "Unable to acquire an authorization code from the Microsoft Azure Active Directory endpoint." -ForegroundColor Red + $authorizationCodeResult = Invoke-AuthorizationCodeFlow @authFlowParams + $graphAccessToken = $authorizationCodeResult.GraphAccessToken + + if ($authorizationCodeResult.AuthorizationCodeFlowUnavailable) { + if ($AuthFlow -eq "Auto") { + Write-Host "Unable to complete browser-based authentication. Falling back to device code authentication." -ForegroundColor Yellow + $graphAccessToken = Invoke-DeviceCodeFlow @authFlowParams + } else { + Write-Host $authorizationCodeResult.AuthorizationCodeFailureMessage -ForegroundColor Red + } + } } } end { - if ($connectionSuccessful) { - return [PSCustomObject]@{ - AccessToken = $tokens.access_token - TenantId = $idTokenPayload.tid - } + if ($null -ne $graphAccessToken) { + return $graphAccessToken } return $null