Modules/MDOP2.Auth.psm1
|
Set-StrictMode -Version Latest Import-Module (Join-Path $PSScriptRoot 'MDOP2.Common.psm1') $script:GraphAppId = '00000003-0000-0000-c000-000000000000' $script:GraphBase = 'https://graph.microsoft.com/v1.0' $script:JitPrefix = 'phishtool-jit-' $script:DelegatedScopes = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All') # Microsoft Graph Command Line Tools, the public client that Connect-MgGraph uses by default. $script:GraphCliClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e' $script:DelegatedScopeString = (@($script:DelegatedScopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'openid', 'profile', 'offline_access') -join ' ' $script:LoginBase = 'https://login.microsoftonline.com' $script:Session = $null #region Pure helpers (covered by Pester) function New-MDOP2JitSecretName { [CmdletBinding()] [OutputType([string])] param([datetime]$Now = [datetime]::UtcNow) $script:JitPrefix + $Now.ToUniversalTime().ToString('yyyyMMdd-HHmmss') } function Select-MDOP2JitCredential { <# .SYNOPSIS Returns the password credentials that were created by this tool, based on the display name prefix. #> [CmdletBinding()] param([AllowNull()][object[]]$PasswordCredentials) if (-not $PasswordCredentials) { return @() } @($PasswordCredentials | Where-Object { $name = if ($_ -is [System.Collections.IDictionary]) { $_['displayName'] } else { $_.displayName } $name -and $name.StartsWith($script:JitPrefix, [StringComparison]::Ordinal) }) } function ConvertFrom-MDOP2Jwt { <# .SYNOPSIS Decodes the payload of a JWT without validating it. Only used to read the roles claim of our own token. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$Token) $parts = $Token.Split('.') if ($parts.Count -lt 2) { throw 'Not a JWT.' } $payload = $parts[1].Replace('-', '+').Replace('_', '/') switch ($payload.Length % 4) { 2 { $payload += '==' } 3 { $payload += '=' } 1 { throw 'Invalid JWT payload length.' } } $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) $json | ConvertFrom-Json } function Test-MDOP2TokenHasRole { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$Role ) $claims = ConvertFrom-MDOP2Jwt -Token $Token $roles = if ($claims.PSObject.Properties['roles']) { @($claims.roles) } else { @() } $roles -contains $Role } function ConvertTo-MDOP2Base64Url { [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)][byte[]]$Bytes) [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') } function New-MDOP2PkcePair { <# .SYNOPSIS Returns a PKCE code verifier (32 random bytes, base64url) and its S256 code challenge. #> [CmdletBinding()] param() $bytes = [byte[]]::new(32) [Security.Cryptography.RandomNumberGenerator]::Fill($bytes) $verifier = ConvertTo-MDOP2Base64Url -Bytes $bytes $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier)) [pscustomobject]@{ Verifier = $verifier; Challenge = (ConvertTo-MDOP2Base64Url -Bytes $hash) } } function New-MDOP2AuthorizeUrl { [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$RedirectUri, [Parameter(Mandatory)][string]$CodeChallenge, [Parameter(Mandatory)][string]$State, [string]$ClientId = $script:GraphCliClientId, [string]$Scope = $script:DelegatedScopeString ) $query = [ordered]@{ client_id = $ClientId response_type = 'code' redirect_uri = $RedirectUri response_mode = 'query' scope = $Scope state = $State code_challenge = $CodeChallenge code_challenge_method = 'S256' prompt = 'select_account' } $pairs = foreach ($key in $query.Keys) { "$key=$([uri]::EscapeDataString($query[$key]))" } "https://login.microsoftonline.com/$([uri]::EscapeDataString($Tenant))/oauth2/v2.0/authorize?$($pairs -join '&')" } function Read-MDOP2AuthCallback { <# .SYNOPSIS Reads the query string of the sign-in redirect. Returns the authorization code, $null when the state doesn't match (not our request), or throws when the sign-in returned an error. #> [CmdletBinding()] param( [AllowEmptyString()][string]$Query, [Parameter(Mandatory)][string]$ExpectedState ) $values = @{} foreach ($pair in $Query.TrimStart('?').Split('&', [StringSplitOptions]::RemoveEmptyEntries)) { $kv = $pair.Split('=', 2) $name = [uri]::UnescapeDataString($kv[0].Replace('+', ' ')) $values[$name] = if ($kv.Count -eq 2) { [uri]::UnescapeDataString($kv[1].Replace('+', ' ')) } else { '' } } if ($values['error']) { # Only printable characters, cut short: this text comes from a request to our listener. $desc = [string]$values['error_description'] $desc = (($desc -split "`r?`n")[0] -replace '[^\x20-\x7E]', '') if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) } $err = $values['error'] -replace '[^A-Za-z0-9_]', '' throw ("Sign-in failed: $err $desc".TrimEnd()) } if ($values['state'] -cne $ExpectedState) { return $null } if (-not $values['code']) { return $null } $values['code'] } function Get-MDOP2MissingScope { <# .SYNOPSIS Compares the space separated scope list of a token response with the required Graph scopes. #> [CmdletBinding()] param( [AllowEmptyString()][string]$Granted, [Parameter(Mandatory)][string[]]$Required ) $have = @($Granted.Split(' ', [StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_ -replace '^https://graph\.microsoft\.com/', '' }) , @($Required | Where-Object { $have -notcontains $_ }) } #endregion #region Delegated Graph (admin sign-in) function Read-MDOP2TokenResponse { # Returns the parsed token response, or throws with the first line of error_description (it has the AADSTS code). param([Parameter(Mandatory)]$Response, [Parameter(Mandatory)][string]$What) $json = $null try { $json = $Response.Content | ConvertFrom-Json } catch { } if ([int]$Response.StatusCode -eq 200 -and $json -and $json.PSObject.Properties['access_token']) { return $json } $desc = if ($json -and $json.PSObject.Properties['error_description']) { ($json.error_description -split "`r?`n")[0] } else { "HTTP $([int]$Response.StatusCode)" } throw "$What failed: $desc" } function Set-MDOP2DelegatedToken { param([Parameter(Mandatory)]$Session, [Parameter(Mandatory)]$TokenResponse) $Session.DelegatedToken = $TokenResponse.access_token $Session.DelegatedExpiry = [datetime]::UtcNow.AddSeconds([int]$TokenResponse.expires_in - 300) if ($TokenResponse.PSObject.Properties['refresh_token'] -and $TokenResponse.refresh_token) { # Always keep the newest refresh token and drop the old one. if ($Session.RefreshToken) { $Session.RefreshToken.Dispose() } $Session.RefreshToken = ConvertTo-SecureString -String $TokenResponse.refresh_token -AsPlainText -Force } } function Get-MDOP2DelegatedToken { <# .SYNOPSIS Returns the admin's delegated Graph token. Renewed with the refresh token 5 minutes before expiry. #> [CmdletBinding()] param() $session = Get-MDOP2Session if ($session.DelegatedToken -and [datetime]::UtcNow -lt $session.DelegatedExpiry) { return $session.DelegatedToken } if (-not $session.RefreshToken) { throw 'The admin sign-in has expired. Start the tool again.' } $body = @{ client_id = $script:GraphCliClientId grant_type = 'refresh_token' refresh_token = [Net.NetworkCredential]::new('', $session.RefreshToken).Password scope = $script:DelegatedScopeString } $resp = Invoke-WebRequest -Method Post -Uri "$script:LoginBase/$($session.TenantId)/oauth2/v2.0/token" -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck $body = $null $json = Read-MDOP2TokenResponse -Response $resp -What 'Renewing the admin sign-in' Set-MDOP2DelegatedToken -Session $session -TokenResponse $json $session.DelegatedToken } function Resolve-MDOP2GraphUri { # A path goes to Graph v1.0. A full URL (an @odata.nextLink) must point at Graph, so a token never goes anywhere else. param([Parameter(Mandatory)][string]$Path) if ($Path.StartsWith('/')) { return "$script:GraphBase$Path" } if ($Path.StartsWith('https://graph.microsoft.com/', [StringComparison]::OrdinalIgnoreCase)) { return $Path } throw 'Refusing to send the Graph token to a URL outside https://graph.microsoft.com/.' } function Get-MDOP2RetryAfter { param([Parameter(Mandatory)]$Response) if ($Response.Headers.ContainsKey('Retry-After')) { [string]$Response.Headers['Retry-After'][0] } } function New-MDOP2GraphError { # The exception for a failed Graph call, with the HTTP status and the Graph error code in Data param([Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)]$Response) $status = [int]$Response.StatusCode $code = $null; $message = $Response.Content try { $err = ($Response.Content | ConvertFrom-Json).error $code = $err.code; $message = $err.message } catch { } $ex = [System.Exception]::new("Graph $Method failed with HTTP ${status}: $code $message") $ex.Data['StatusCode'] = $status $ex.Data['ErrorCode'] = $code $ex } function Invoke-MDOP2GraphRequest { # One Graph call with the admin's token (-Delegated) or the app-only token. Retries 429/5xx and the extra statuses # in RetryStatus with Retry-After or backoff. Returns the response, or throws New-MDOP2GraphError. param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Path, [object]$Body, [int]$MaxAttempts = 6, [switch]$Delegated, [int[]]$RetryStatus = @() ) $uri = Resolve-MDOP2GraphUri -Path $Path $json = if ($null -ne $Body) { $Body | ConvertTo-Json -Depth 10 -Compress } else { $null } for ($attempt = 1; ; $attempt++) { $token = if ($Delegated) { Get-MDOP2DelegatedToken } else { Get-MDOP2AppToken } $params = @{ Method = $Method; Uri = $uri; Headers = @{ Authorization = "Bearer $token" }; SkipHttpErrorCheck = $true } if ($null -ne $json) { $params.Body = $json; $params.ContentType = 'application/json' } $resp = Invoke-WebRequest @params $status = [int]$resp.StatusCode if ($status -ge 200 -and $status -lt 300) { return $resp } $retryable = (Test-MDOP2RetryableStatus -StatusCode $status) -or $status -in $RetryStatus if (-not $retryable -or $attempt -ge $MaxAttempts) { throw (New-MDOP2GraphError -Method $Method -Response $resp) } $delay = Get-MDOP2RetryDelay -Attempt $attempt -RetryAfter (Get-MDOP2RetryAfter -Response $resp) Write-Verbose "Graph returned $status for $Method. Retrying in $delay s." Start-Sleep -Seconds $delay } } function Invoke-MDOP2DelegatedGraph { <# .SYNOPSIS Calls Microsoft Graph with the admin's delegated token. Only used to manage the app registration and its secret. #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method, [Parameter(Mandatory)][string]$Path, [object]$Body, [int]$MaxAttempts = 6, # Directory objects that were just created can take a moment to replicate. Retry 400/404 when set. [switch]$RetryOnReplication ) $retry = if ($RetryOnReplication) { 400, 404 } else { @() } $resp = Invoke-MDOP2GraphRequest -Delegated -Method $Method -Path $Path -Body $Body -MaxAttempts $MaxAttempts -RetryStatus $retry if ($resp.Content) { $resp.Content | ConvertFrom-Json } } function Send-MDOP2ListenerReply { param([Parameter(Mandatory)]$Context, [int]$Status = 200, [string]$Text = '') try { $html = "<!doctype html><meta charset=`"utf-8`"><title>Invoke-MDOP2-Incident-Response</title><body style=`"font-family:sans-serif;margin:3em`"><p>$([Net.WebUtility]::HtmlEncode($Text))</p></body>" $bytes = [Text.Encoding]::UTF8.GetBytes($html) $Context.Response.StatusCode = $Status $Context.Response.ContentType = 'text/html; charset=utf-8' $Context.Response.Headers['Cache-Control'] = 'no-store' $Context.Response.Headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'" $Context.Response.ContentLength64 = $bytes.Length $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length) } catch { Write-Verbose "Could not answer the browser: $_" } finally { try { $Context.Response.Close() } catch { } } } function Invoke-MDOP2BrowserSignIn { <# .SYNOPSIS Signs the admin in with the OAuth authorization code flow and PKCE in a browser, with a redirect to a listener on localhost. Doesn't use the Windows sign-in broker (WAM). Returns the token response. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [switch]$NoBrowser, [int]$TimeoutSeconds = 300 ) $redirectUri = "http://localhost:$(Get-MDOP2FreePort)/" $pkce = New-MDOP2PkcePair $stateBytes = [byte[]]::new(16) [Security.Cryptography.RandomNumberGenerator]::Fill($stateBytes) $state = ConvertTo-MDOP2Base64Url -Bytes $stateBytes $url = New-MDOP2AuthorizeUrl -Tenant $Tenant -RedirectUri $redirectUri -CodeChallenge $pkce.Challenge -State $state $listener = [Net.HttpListener]::new() $listener.Prefixes.Add($redirectUri) $listener.Start() try { Write-Host 'Sign in to Microsoft Graph in your browser. If it does not open, open this link:' Write-Host $url if (-not $NoBrowser) { Start-Process $url } $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds) $code = $null $pending = $listener.GetContextAsync() while (-not $code) { # Short waits so Ctrl+C still works. if (-not $pending.Wait(500)) { if ([datetime]::UtcNow -ge $deadline) { throw "Sign-in timed out after $TimeoutSeconds seconds." } continue } $ctx = $pending.Result $pending = $null if ($ctx.Request.Url.AbsolutePath -ne '/' -or $ctx.Request.HttpMethod -ne 'GET') { Send-MDOP2ListenerReply -Context $ctx -Status 404 -Text 'Not found.' } else { try { $code = Read-MDOP2AuthCallback -Query $ctx.Request.Url.Query -ExpectedState $state } catch { Send-MDOP2ListenerReply -Context $ctx -Status 400 -Text 'Sign-in failed. See the PowerShell window.' throw } if ($code) { Send-MDOP2ListenerReply -Context $ctx -Text 'Signed in. You can close this tab and return to PowerShell.' } else { Send-MDOP2ListenerReply -Context $ctx -Status 400 -Text 'Unexpected request.' } } if (-not $code) { $pending = $listener.GetContextAsync() } } } finally { $listener.Stop() $listener.Close() } $body = @{ client_id = $script:GraphCliClientId grant_type = 'authorization_code' code = $code redirect_uri = $redirectUri code_verifier = $pkce.Verifier scope = $script:DelegatedScopeString } $resp = Invoke-WebRequest -Method Post -Uri "$script:LoginBase/$Tenant/oauth2/v2.0/token" -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck $body = $null; $code = $null Read-MDOP2TokenResponse -Response $resp -What 'Sign-in' } function Connect-MDOP2Admin { <# .SYNOPSIS Signs the admin in to Microsoft Graph and Exchange Online in a browser. Tokens are kept in memory only. #> [CmdletBinding()] param( [string]$TenantId, [switch]$SkipExchange, # Print the Graph sign-in link instead of opening it in the default browser. [switch]$NoBrowser ) $tenant = if ($TenantId) { $TenantId } else { 'organizations' } $tokens = Invoke-MDOP2BrowserSignIn -Tenant $tenant -NoBrowser:$NoBrowser if (-not $tokens.PSObject.Properties['id_token']) { throw 'Sign-in returned no ID token.' } $claims = ConvertFrom-MDOP2Jwt -Token $tokens.id_token $tid = $claims.tid $upn = if ($claims.PSObject.Properties['preferred_username']) { $claims.preferred_username } else { $null } if (-not $tid -or -not $upn) { throw 'Sign-in did not return the tenant or account name.' } $granted = if ($tokens.PSObject.Properties['scope']) { [string]$tokens.scope } else { '' } $missing = Get-MDOP2MissingScope -Granted $granted -Required $script:DelegatedScopes if ($missing.Count) { throw "Sign-in is missing the delegated scopes: $($missing -join ', ')." } $session = [ordered]@{ TenantId = $tid AdminUpn = $upn DelegatedToken = $null DelegatedExpiry = [datetime]::MinValue RefreshToken = $null AppId = $null AppObjectId = $null SecretKeyId = $null Secret = $null Token = $null TokenExpiry = [datetime]::MinValue } Set-MDOP2DelegatedToken -Session $session -TokenResponse $tokens $tokens = $null $script:Session = $session if (-not $SkipExchange) { Write-Host 'Sign in to Exchange Online in the browser window that opens.' Connect-ExchangeOnline -UserPrincipalName $upn -DisableWAM -ShowBanner:$false -ErrorAction Stop } [pscustomobject]@{ TenantId = $tid; AdminUpn = $upn } } function Disconnect-MDOP2Admin { [CmdletBinding()] param() try { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction Stop | Out-Null } catch { Write-Verbose "Exchange disconnect: $_" } if ($script:Session) { if ($script:Session.RefreshToken) { $script:Session.RefreshToken.Dispose() } $script:Session.RefreshToken = $null $script:Session.DelegatedToken = $null } $script:Session = $null } function Get-MDOP2Session { if (-not $script:Session) { throw 'Not signed in. Call Connect-MDOP2Admin first.' } $script:Session } #endregion #region App registration and consent function Initialize-MDOP2App { <# .SYNOPSIS Makes sure the tool's app registration, its service principal and the admin-consented Mail.ReadWrite application permission exist. Safe to run every time. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$DisplayName) $session = Get-MDOP2Session $changes = [Collections.Generic.List[string]]::new() $graphSp = Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals(appId='$script:GraphAppId')?`$select=id,appRoles" $role = @($graphSp.appRoles | Where-Object { $_.value -eq 'Mail.ReadWrite' -and $_.allowedMemberTypes -contains 'Application' }) if ($role.Count -ne 1) { throw 'Could not find the Mail.ReadWrite application role on Microsoft Graph.' } $roleId = $role[0].id $filter = [uri]::EscapeDataString("displayName eq $(ConvertTo-MDOP2ODataLiteral $DisplayName)") $apps = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/applications?`$filter=$filter&`$select=id,appId,displayName").value) if ($apps.Count -gt 1) { throw "Found $($apps.Count) app registrations named '$DisplayName'. Remove the duplicates or use -AppDisplayName." } if ($apps.Count -eq 1) { $app = $apps[0] } else { $app = Invoke-MDOP2DelegatedGraph -Method POST -Path '/applications' -Body @{ displayName = $DisplayName signInAudience = 'AzureADMyOrg' requiredResourceAccess = @(@{ resourceAppId = $script:GraphAppId resourceAccess = @(@{ id = $roleId; type = 'Role' }) }) } $changes.Add('Created app registration') } $spFilter = [uri]::EscapeDataString("appId eq $(ConvertTo-MDOP2ODataLiteral $app.appId)") $sps = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals?`$filter=$spFilter&`$select=id,appId").value) if ($sps.Count -ge 1) { $sp = $sps[0] } else { $sp = Invoke-MDOP2DelegatedGraph -Method POST -Path '/servicePrincipals' -Body @{ appId = $app.appId } -RetryOnReplication $changes.Add('Created service principal') } $assignments = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals/$($sp.id)/appRoleAssignments" -RetryOnReplication).value) $hasRole = @($assignments | Where-Object { $_.resourceId -eq $graphSp.id -and $_.appRoleId -eq $roleId }).Count -gt 0 if (-not $hasRole) { Invoke-MDOP2DelegatedGraph -Method POST -Path "/servicePrincipals/$($graphSp.id)/appRoleAssignedTo" -RetryOnReplication -Body @{ principalId = $sp.id resourceId = $graphSp.id appRoleId = $roleId } | Out-Null $changes.Add('Granted admin consent for Mail.ReadWrite (application)') } $session.AppId = $app.appId $session.AppObjectId = $app.id [pscustomobject]@{ AppId = $app.appId AppObjectId = $app.id ServicePrincipalId = $sp.id Changes = $changes.ToArray() } } #endregion #region Just-in-time secret function Remove-MDOP2StaleJitSecret { <# .SYNOPSIS Removes secrets left behind by earlier sessions that did not shut down cleanly. #> [CmdletBinding()] param() $session = Get-MDOP2Session $app = Invoke-MDOP2DelegatedGraph -Method GET -Path "/applications/$($session.AppObjectId)?`$select=passwordCredentials" $stale = Select-MDOP2JitCredential -PasswordCredentials @($app.passwordCredentials) foreach ($cred in $stale) { if ($cred.keyId -eq $session.SecretKeyId) { continue } Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/removePassword" -Body @{ keyId = $cred.keyId } | Out-Null [pscustomobject]@{ KeyId = $cred.keyId; DisplayName = $cred.displayName } } } function New-MDOP2JitSecret { [CmdletBinding()] param([ValidateRange(1, 8)][int]$LifetimeHours = 2) $session = Get-MDOP2Session $end = [datetime]::UtcNow.AddHours($LifetimeHours) $result = Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/addPassword" -Body @{ passwordCredential = @{ displayName = New-MDOP2JitSecretName endDateTime = ConvertTo-MDOP2IsoDate $end } } $session.SecretKeyId = $result.keyId $session.Secret = ConvertTo-SecureString -String $result.secretText -AsPlainText -Force $result.secretText = $null [pscustomobject]@{ KeyId = $result.keyId; DisplayName = $result.displayName; Expires = $end } } function Remove-MDOP2JitSecret { [CmdletBinding()] param() if (-not $script:Session -or -not $script:Session.SecretKeyId) { return $false } $session = $script:Session try { Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/removePassword" -Body @{ keyId = $session.SecretKeyId } | Out-Null return $true } finally { $session.SecretKeyId = $null if ($session.Secret) { $session.Secret.Dispose() } $session.Secret = $null $session.Token = $null $session.TokenExpiry = [datetime]::MinValue } } #endregion #region App-only Graph function Get-MDOP2AppToken { <# .SYNOPSIS Returns an app-only Graph token from the client credentials flow. Kept in memory and refreshed 5 minutes before expiry. #> [CmdletBinding()] param([int]$SecretPropagationTimeoutSeconds = 120, [int]$MaxAttempts = 4) $session = Get-MDOP2Session if ($session.Token -and [datetime]::UtcNow -lt $session.TokenExpiry) { return $session.Token } if (-not $session.Secret) { throw 'No JIT secret in this session.' } $uri = "https://login.microsoftonline.com/$($session.TenantId)/oauth2/v2.0/token" $deadline = [datetime]::UtcNow.AddSeconds($SecretPropagationTimeoutSeconds) $failures = 0 for ($attempt = 1; ; $attempt++) { $body = @{ client_id = $session.AppId client_secret = [Net.NetworkCredential]::new('', $session.Secret).Password scope = 'https://graph.microsoft.com/.default' grant_type = 'client_credentials' } $resp = Invoke-WebRequest -Method Post -Uri $uri -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck $body = $null $status = [int]$resp.StatusCode # An error page from a proxy is not JSON and must still end in a clear error $json = $null try { $json = $resp.Content | ConvertFrom-Json } catch { Write-Verbose "Token response with HTTP $status is not JSON." } if ($json -isnot [Management.Automation.PSCustomObject]) { $json = [pscustomobject]@{} } if ($status -eq 200 -and $json.PSObject.Properties['access_token']) { $session.Token = $json.access_token $session.TokenExpiry = [datetime]::UtcNow.AddSeconds([int]$json.expires_in - 300) return $session.Token } # A brand-new secret is not always accepted straight away (AADSTS7000215). Wait and retry. $codes = if ($json.PSObject.Properties['error_codes']) { @($json.error_codes) } else { @() } if ($codes -contains 7000215 -and [datetime]::UtcNow -lt $deadline) { $delay = Get-MDOP2RetryDelay -Attempt $attempt -MaxSeconds 20 Write-Verbose "New secret not accepted yet. Retrying in $delay s." Start-Sleep -Seconds $delay continue } if (Test-MDOP2RetryableStatus -StatusCode $status) { $failures++ if ($failures -lt $MaxAttempts) { $delay = Get-MDOP2RetryDelay -Attempt $failures -RetryAfter (Get-MDOP2RetryAfter -Response $resp) -MaxSeconds 30 Write-Verbose "Token request returned $status. Retrying in $delay s." Start-Sleep -Seconds $delay continue } } $desc = if ($json.PSObject.Properties['error_description']) { ([string]$json.error_description -split "`r?`n")[0] } else { "HTTP $status" } throw "App-only token request failed: $desc" } } function Invoke-MDOP2AppGraph { <# .SYNOPSIS Calls Microsoft Graph with the app-only token. Retries 429/5xx with Retry-After or backoff and follows paging when -All is set. #> [CmdletBinding()] param( [Parameter(Mandatory)][ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method, [Parameter(Mandatory)][string]$Path, [object]$Body, [switch]$All, [switch]$Raw, [int]$MaxAttempts = 6 ) $uri = $Path $items = [Collections.Generic.List[object]]::new() while ($uri) { $resp = Invoke-MDOP2GraphRequest -Method $Method -Path $uri -Body $Body -MaxAttempts $MaxAttempts if ($Raw) { return $resp } if (-not $resp.Content) { return $null } $json = $resp.Content | ConvertFrom-Json if (-not $All) { return $json } if ($json.PSObject.Properties['value']) { foreach ($v in $json.value) { $items.Add($v) } } $uri = if ($json.PSObject.Properties['@odata.nextLink']) { $json.'@odata.nextLink' } else { $null } $Body = $null } $items.ToArray() } function Invoke-MDOP2AppGraphBatch { <# .SYNOPSIS Sends GET requests through Graph JSON batching, 20 per batch and one batch at a time. Graph runs at most four requests of a batch at once, which stays within the Outlook limit of four concurrent requests per mailbox. Throttled and failed requests (429/5xx) are retried after the longest Retry-After. With -All, paged collections are followed. Returns a hashtable keyed by request ID; each result has Status, Value (collection items), Body, ErrorCode and Error. #> [CmdletBinding()] param( [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Request, [switch]$All, [int]$MaxAttempts = 6 ) $results = @{} $attempts = @{} $queue = [Collections.Generic.List[object]]::new() foreach ($r in $Request) { $queue.Add($r) } while ($queue.Count) { $take = [Math]::Min(20, $queue.Count) $batch = $queue.GetRange(0, $take) $queue.RemoveRange(0, $take) $responses = @{} try { $body = @{ requests = @($batch | ForEach-Object { @{ id = [string]$_.Id; method = 'GET'; url = [string]$_.Url } }) } $resp = Invoke-MDOP2AppGraph -Method POST -Path '/$batch' -Body $body -MaxAttempts $MaxAttempts if ($resp -and $resp.PSObject.Properties['responses']) { foreach ($x in @($resp.responses)) { $responses[[string]$x.id] = $x } } } catch { $sc = [int]($_.Exception.Data['StatusCode'] ?? 0) foreach ($r in $batch) { $results[[string]$r.Id] = [pscustomobject]@{ Status = $sc; Value = @(); Body = $null; ErrorCode = $_.Exception.Data['ErrorCode']; Error = $_.Exception.Message } } continue } $retry = [Collections.Generic.List[object]]::new() $wait = 0 foreach ($r in $batch) { $id = [string]$r.Id $x = $responses[$id] if (-not $x) { $results[$id] = [pscustomobject]@{ Status = 0; Value = @(); Body = $null; ErrorCode = $null; Error = 'Graph batch returned no response for this request.' } continue } $status = [int]$x.status $b = if ($x.PSObject.Properties['body']) { $x.body } else { $null } if ($status -ge 200 -and $status -lt 300) { $value = @() $pageError = $null if ($All -and $b -and $b.PSObject.Properties['value']) { $items = [Collections.Generic.List[object]]::new() foreach ($v in @($b.value)) { $items.Add($v) } if ($b.PSObject.Properties['@odata.nextLink'] -and $b.'@odata.nextLink') { try { foreach ($v in @(Invoke-MDOP2AppGraph -Method GET -Path ([string]$b.'@odata.nextLink') -All)) { $items.Add($v) } } catch { $pageError = $_ } } $value = $items.ToArray() } $results[$id] = if ($pageError) { [pscustomobject]@{ Status = [int]($pageError.Exception.Data['StatusCode'] ?? 0); Value = @(); Body = $null ErrorCode = $pageError.Exception.Data['ErrorCode']; Error = $pageError.Exception.Message } } else { [pscustomobject]@{ Status = $status; Value = $value; Body = $b; ErrorCode = $null; Error = $null } } continue } $n = [int]($attempts[$id] ?? 0) + 1 if ((Test-MDOP2RetryableStatus -StatusCode $status) -and $n -lt $MaxAttempts) { $attempts[$id] = $n $ra = $null if ($x.PSObject.Properties['headers'] -and $x.headers -and $x.headers.PSObject.Properties['Retry-After']) { $ra = [string]$x.headers.'Retry-After' } $wait = [Math]::Max($wait, (Get-MDOP2RetryDelay -Attempt $n -RetryAfter $ra)) $retry.Add($r) continue } $code = $null $message = '' if ($b -and $b.PSObject.Properties['error'] -and $b.error) { if ($b.error.PSObject.Properties['code']) { $code = [string]$b.error.code } if ($b.error.PSObject.Properties['message']) { $message = [string]$b.error.message } } $results[$id] = [pscustomobject]@{ Status = $status; Value = @(); Body = $b; ErrorCode = $code; Error = "Graph GET failed with HTTP ${status}: $code $message" } } if ($retry.Count) { Write-Verbose "Graph batch: $($retry.Count) request(s) throttled or failed. Retrying in $wait s." Start-Sleep -Seconds $wait $queue.InsertRange(0, $retry) } } $results } #endregion Export-ModuleMember -Function New-MDOP2JitSecretName, Select-MDOP2JitCredential, Test-MDOP2TokenHasRole, ConvertTo-MDOP2Base64Url, New-MDOP2PkcePair, New-MDOP2AuthorizeUrl, Read-MDOP2AuthCallback, Get-MDOP2MissingScope, Connect-MDOP2Admin, Disconnect-MDOP2Admin, Get-MDOP2Session, Initialize-MDOP2App, Remove-MDOP2StaleJitSecret, New-MDOP2JitSecret, Remove-MDOP2JitSecret, Get-MDOP2AppToken, Invoke-MDOP2AppGraph, Invoke-MDOP2AppGraphBatch |