Skip to content

Commit 101e167

Browse files
Adding browser flow
1 parent 233533b commit 101e167

8 files changed

+250
-72
lines changed

MiniGraph/MiniGraph.psd1

+8-7
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
RootModule = 'MiniGraph.psm1'
55

66
# Version number of this module.
7-
ModuleVersion = '1.2.7'
7+
ModuleVersion = '1.3.12'
88

99
# Supported PSEditions
1010
# CompatiblePSEditions = @()
@@ -63,14 +63,15 @@ Description = 'Minimal query infrastructure for interacting with MS Graph'
6363
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
6464
FunctionsToExport = @(
6565
'Connect-GraphAzure'
66-
'Connect-GraphCertificate'
67-
'Connect-GraphClientSecret'
68-
'Connect-GraphCredential'
66+
'Connect-GraphBrowser'
67+
'Connect-GraphCertificate'
68+
'Connect-GraphClientSecret'
69+
'Connect-GraphCredential'
6970
'Connect-GraphDeviceCode'
7071
'Connect-GraphToken'
71-
'Invoke-GraphRequest'
72-
'Invoke-GraphRequestBatch'
73-
'Set-GraphEndpoint'
72+
'Invoke-GraphRequest'
73+
'Invoke-GraphRequestBatch'
74+
'Set-GraphEndpoint'
7475
)
7576

7677
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
function Connect-GraphBrowser {
2+
<#
3+
.SYNOPSIS
4+
Interactive logon using the Authorization flow and browser. Supports SSO.
5+
6+
.DESCRIPTION
7+
Interactive logon using the Authorization flow and browser. Supports SSO.
8+
9+
This flow requires an App Registration configured for the platform "Mobile and desktop applications".
10+
Its redirect Uri must be "http://localhost"
11+
12+
On successful authentication
13+
14+
.PARAMETER ClientID
15+
The ID of the registered app used with this authentication request.
16+
17+
.PARAMETER TenantID
18+
The ID of the tenant connected to with this authentication request.
19+
20+
.PARAMETER SelectAccount
21+
As this flow supports single-sign-on, it will not prompt for anything if
22+
23+
.PARAMETER Scopes
24+
Generally doesn't need to be changed from the default 'https://graph.microsoft.com/.default'
25+
26+
.PARAMETER LocalPort
27+
The local port that should be redirected to.
28+
In order to process the authentication response, we need to listen to a local web request on some port.
29+
Usually needs not be redirected.
30+
Defaults to: 8080
31+
32+
.PARAMETER Resource
33+
The resource the token grants access to.
34+
Generally doesn't need to be changed from the default 'https://graph.microsoft.com/'
35+
Only needed when connecting to another service.
36+
37+
.PARAMETER Browser
38+
The path to the browser to use for the authentication flow.
39+
Provide the full path to the executable.
40+
The browser must accept the url to open as its only parameter.
41+
Defaults to Edge.
42+
43+
.PARAMETER NoReconnect
44+
Disables automatic reconnection.
45+
By default, MiniGraph will automatically try to reaquire a new token before the old one expires.
46+
47+
.EXAMPLE
48+
PS C:\> Connect-GraphBrowser -ClientID '<ClientID>' -TenantID '<TenantID>'
49+
50+
Connects to the specified tenant using the specified client, prompting the user to authorize via Browser.
51+
#>
52+
[CmdletBinding()]
53+
param (
54+
[Parameter(Mandatory = $true)]
55+
[string]
56+
$TenantID,
57+
58+
[Parameter(Mandatory = $true)]
59+
[string]
60+
$ClientID,
61+
62+
[switch]
63+
$SelectAccount,
64+
65+
[string[]]
66+
$Scopes = 'https://graph.microsoft.com/.default',
67+
68+
[int]
69+
$LocalPort = 8080,
70+
71+
[Uri]
72+
$Resource = 'https://graph.microsoft.com/',
73+
74+
[string]
75+
$Browser = $script:browserPath,
76+
77+
[switch]
78+
$NoReconnect
79+
)
80+
process {
81+
Add-Type -AssemblyName System.Web
82+
83+
$redirectUri = "http://localhost:$LocalPort"
84+
$actualScopes = foreach ($scope in $Scopes) {
85+
if ($scope -like 'https://*/*') { $scope }
86+
else { "{0}://{1}/{2}" -f $Resource.Scheme, $Resource.Host, $scope }
87+
}
88+
89+
if (-not $NoReconnect) {
90+
$actualScopes = @($actualScopes) + 'offline_access'
91+
}
92+
93+
$uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/authorize?"
94+
$state = Get-Random
95+
$parameters = @{
96+
client_id = $ClientID
97+
response_type = 'code'
98+
redirect_uri = $redirectUri
99+
response_mode = 'query'
100+
scope = $Scopes -join ' '
101+
state = $state
102+
}
103+
if ($SelectAccount) {
104+
$parameters.prompt = 'select_account'
105+
}
106+
107+
$paramStrings = foreach ($pair in $parameters.GetEnumerator()) {
108+
$pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '='
109+
}
110+
$uriFinal = $uri + ($paramStrings -join '&')
111+
Write-Verbose "Authorize Uri: $uriFinal"
112+
113+
$redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/MiniGraph/master/nothing-to-see-here.txt'
114+
if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) {
115+
$redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
116+
}
117+
118+
# Start local server to catch the redirect
119+
$http = [System.Net.HttpListener]::new()
120+
$http.Prefixes.Add("$redirectUri/")
121+
try { $http.Start() }
122+
catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError }
123+
124+
# Execute in default browser
125+
& $Browser $uriFinal
126+
127+
# Get Result
128+
$task = $http.GetContextAsync()
129+
$authorizationCode, $stateReturn, $sessionState = $null
130+
try {
131+
while (-not $task.IsCompleted) {
132+
Start-Sleep -Milliseconds 200
133+
}
134+
$context = $task.Result
135+
$context.Response.Redirect($redirectTo)
136+
$context.Response.Close()
137+
$authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&"
138+
}
139+
finally {
140+
$http.Stop()
141+
$http.Dispose()
142+
}
143+
144+
if (-not $stateReturn) {
145+
Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError
146+
}
147+
148+
if ($state -ne $stateReturn.Split("=")[1]) {
149+
Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation
150+
}
151+
152+
$actualAuthorizationCode = $authorizationCode.Split("=")[1]
153+
154+
$body = @{
155+
client_id = $ClientID
156+
scope = $actualScopes -join " "
157+
code = $actualAuthorizationCode
158+
redirect_uri = $redirectUri
159+
grant_type = 'authorization_code'
160+
}
161+
$uri = "https://login.microsoftonline.com/$TenantID/oauth2/v2.0/token"
162+
try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop }
163+
catch {
164+
if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ }
165+
Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category
166+
}
167+
$script:token = $authResponse.access_token
168+
169+
Set-ReconnectInfo -BoundParameters $PSBoundParameters -NoReconnect:$NoReconnect -RefreshToken $authResponse.refresh_token
170+
}
171+
}

MiniGraph/functions/Connect-GraphCertificate.ps1

+9-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
.PARAMETER ClientID
1616
The ClientID / ApplicationID of the application to connect as.
1717
18+
.PARAMETER Scopes
19+
The scopes to request when connecting.
20+
IN Application flows, this only determines the service for which to retrieve the scopes already configured on the App Registration.
21+
Defaults to graph API.
22+
1823
.PARAMETER NoReconnect
1924
Disables automatic reconnection.
2025
By default, MiniGraph will automatically try to reaquire a new token before the old one expires.
@@ -46,6 +51,9 @@
4651
[string]
4752
$ClientID,
4853

54+
[string[]]
55+
$Scopes = 'https://graph.microsoft.com/.default',
56+
4957
[switch]
5058
$NoReconnect
5159
)
@@ -73,7 +81,7 @@
7381
client_id = $ClientID
7482
client_assertion = $jwt
7583
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
76-
scope = 'https://graph.microsoft.com/.default'
84+
scope = $Scopes -join ' '
7785
grant_type = 'client_credentials'
7886
}
7987
$header = @{
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
function Invoke-GraphRequestBatch
2-
{
3-
<#
1+
function Invoke-GraphRequestBatch {
2+
<#
43
.SYNOPSIS
54
Invoke a batch request against the graph API
65
.DESCRIPTION
@@ -24,69 +23,56 @@
2423
$araCounter++
2524
}
2625
#>
27-
param
28-
(
29-
[Parameter(Mandatory)]
30-
[hashtable[]]
31-
$Request
32-
)
26+
[CmdletBinding()]
27+
param (
28+
[Parameter(Mandatory = $true)]
29+
[hashtable[]]
30+
$Request
31+
)
3332

34-
$batchSize = 20 # Currently hardcoded API limit
35-
$counter = [pscustomobject] @{ Value = 0 }
36-
$batches = $Request | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } -AsHashTable
33+
$batchSize = 20 # Currently hardcoded API limit
34+
$counter = [pscustomobject] @{ Value = 0 }
35+
$batches = $Request | Group-Object -Property { [math]::Floor($counter.Value++ / $batchSize) } -AsHashTable
3736

38-
$batchResult = [System.Collections.ArrayList]::new()
37+
foreach ($batch in ($batches.GetEnumerator() | Sort-Object -Property Key)) {
38+
[array] $innerResult = try {
39+
$jsonbody = @{requests = [array]$batch.Value } | ConvertTo-Json -Depth 42 -Compress
40+
(MiniGraph\Invoke-GraphRequest -Query '$batch' -Method Post -Body $jsonbody -ErrorAction Stop).responses
41+
}
42+
catch {
43+
Write-Error -Message "Error sending batch: $($_.Exception.Message)" -TargetObject $jsonbody
44+
continue
45+
}
3946

40-
foreach ($batch in ($batches.GetEnumerator() | Sort-Object -Property Key))
41-
{
42-
[array] $innerResult = try
43-
{
44-
$jsonbody = @{requests = [array]$batch.Value } | ConvertTo-Json -Depth 42 -Compress
45-
(Invoke-GraphRequest -Query '$batch' -Method Post -Body $jsonbody -ErrorAction Stop).responses
46-
}
47-
catch [Microsoft.PowerShell.Commands.HttpResponseException]
48-
{
49-
Write-Error -Message "Error sending batch: $($_.Exception.Message)" -TargetObject $jsonbody
50-
}
47+
$throttledRequests = $innerResult | Where-Object status -EQ 429
48+
$failedRequests = $innerResult | Where-Object { $_.status -ne 429 -and $_.status -in (400..499) }
49+
$successRequests = $innerResult | Where-Object status -In (200..299)
5150

52-
$throttledRequests = $innerResult | Where-Object status -eq 429
53-
$failedRequests = $innerResult | Where-Object { $_.status -ne 429 -and $_.status -in (400..499) }
54-
$successRequests = $innerResult | Where-Object status -in (200..299)
51+
foreach ($failedRequest in $failedRequests) {
52+
Write-Error -Message "Error in batch request $($failedRequest.id): $($failedRequest.body.error.message)"
53+
}
5554

56-
if ($successRequests)
57-
{
58-
$null = $batchResult.AddRange([array]$successRequests)
59-
}
55+
if ($successRequests) {
56+
$successRequests
57+
}
6058

61-
if ($throttledRequests)
62-
{
63-
$interval = ($throttledRequests.Headers | Sort-Object 'Retry-After' | Select-Object -Last 1).'Retry-After'
64-
Write-Verbose -Message "Throttled requests detected, waiting $interval seconds before retrying"
59+
if ($throttledRequests) {
60+
$interval = ($throttledRequests.Headers | Sort-Object 'Retry-After' | Select-Object -Last 1).'Retry-After'
61+
Write-Verbose -Message "Throttled requests detected, waiting $interval seconds before retrying"
6562

66-
Start-Sleep -Seconds $interval
67-
$retry = $Request | Where-Object id -in $throttledRequests.id
63+
Start-Sleep -Seconds $interval
64+
$retry = $Request | Where-Object id -In $throttledRequests.id
6865

69-
if (-not $retry)
70-
{
71-
continue
72-
}
73-
74-
try
75-
{
76-
$retriedResults = [array](Invoke-GraphRequestBatch -Name $Name -Request $retry -NoProgress -ErrorAction Stop).responses
77-
$null = $batchResult.AddRange($retriedResults)
78-
}
79-
catch [Microsoft.PowerShell.Commands.HttpResponseException]
80-
{
81-
Write-Error -Message "Error sending retry batch: $($_.Exception.Message)" -TargetObject $retry
82-
}
83-
}
84-
85-
foreach ($failedRequest in $failedRequests)
86-
{
87-
Write-Error -Message "Error in batch request $($failedRequest.id): $($failedRequest.body.error.message)"
88-
}
89-
}
66+
if (-not $retry) {
67+
continue
68+
}
9069

91-
$batchResult
92-
}
70+
try {
71+
(MiniGraph\Invoke-GraphRequestBatch -Name $Name -Request $retry -NoProgress -ErrorAction Stop).responses
72+
}
73+
catch {
74+
Write-Error -Message "Error sending retry batch: $($_.Exception.Message)" -TargetObject $retry
75+
}
76+
}
77+
}
78+
}

MiniGraph/internal/functions/Invoke-TerminatingException.ps1

+6-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
.PARAMETER ErrorRecord
2424
A full error record that was caught by the caller.
2525
Use this when you want to rethrow an existing error.
26+
27+
.PARAMETER Target
28+
The target of the exception.
2629
2730
.EXAMPLE
2831
PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module'
@@ -44,7 +47,9 @@
4447
$Category = [System.Management.Automation.ErrorCategory]::NotSpecified,
4548

4649
[System.Management.Automation.ErrorRecord]
47-
$ErrorRecord
50+
$ErrorRecord,
51+
52+
$Target
4853
)
4954

5055
process{

MiniGraph/internal/scripts/variables.ps1

+4-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,7 @@ $script:lastConnect = @{
1010
Command = $null
1111
Parameters = $null
1212
Refresh = $null
13-
}
13+
}
14+
15+
# Used for Browser-Based interactive logon
16+
$script:browserPath = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'

0 commit comments

Comments
 (0)