diff --git a/examples/CallingAPIs.ps1 b/examples/CallingAPIs.ps1 new file mode 100644 index 000000000..b1488e439 --- /dev/null +++ b/examples/CallingAPIs.ps1 @@ -0,0 +1,46 @@ +#region As a user: get the authenticated user +Connect-GitHub + +# Simple example - output is the object +Get-GitHubUser + +# More complex example - output is parts of the web response +Invoke-GitHubAPI -ApiEndpoint /user + +# Most complex example - output is the entire web response +$context = Get-GitHubContext +Invoke-WebRequest -Uri "https://api.$($context.HostName)/user" -Token ($context.Token) -Authentication Bearer +#endregion + + +#region As an app: get the authenticated app +$ClientID = '' +$PrivateKey = '' +Connect-GitHub -ClientID $ClientID -PrivateKey $PrivateKey + +# Simple example - output is the object +Get-GitHubApp + +# More complex example - output is parts of the web response +Invoke-GitHubAPI -ApiEndpoint /app + +# Most complex example - output is the entire web response +$context = Get-GitHubContext +$jwt = Get-GitHubAppJSONWebToken -ClientId $context.ClientID -PrivateKey $context.Token +Invoke-WebRequest -Uri "https://api.$($context.HostName)/user" -Token ($jwt.token) -Authentication Bearer +#endregion + + +#region As an app installation: get zen +Connect-GitHubApp -Installation 'PSModule' + +# Simple example - output is the object +Get-GitHuben + +# More complex example - output is parts of the web response +Invoke-GitHubAPI -ApiEndpoint /zen + +# Most complex example - output is the entire web response +$context = Get-GitHubContext +Invoke-WebRequest -Uri "https://api.$($context.HostName)/zen" -Token ($context.Token) -Authentication Bearer +#endregion diff --git a/src/classes/public/GitHubContext.ps1 b/src/classes/public/GitHubContext.ps1 index 902375d21..f51de848e 100644 --- a/src/classes/public/GitHubContext.ps1 +++ b/src/classes/public/GitHubContext.ps1 @@ -42,6 +42,10 @@ # github.com/Octocat [string] $Name + # The context type + # User / App / Installation + [string] $Type + # The user name. [string] $UserName @@ -69,6 +73,9 @@ # 2024-01-01-00:00:00 [datetime] $TokenExpirationDate + # The installation ID. + [int] $InstallationID + # The refresh token. [securestring] $RefreshToken diff --git a/src/completers.ps1 b/src/completers.ps1 index bc6671feb..1b742bed0 100644 --- a/src/completers.ps1 +++ b/src/completers.ps1 @@ -2,10 +2,7 @@ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter - $contexts = Get-GitHubContext -ListAvailable -Verbose:$false | Where-Object { "$($_.HostName)/$($_.UserName)" -like "$wordToComplete*" } - - $contexts | ForEach-Object { - $contextID = "$($_.HostName)/$($_.UserName)" - [System.Management.Automation.CompletionResult]::new($contextID, $contextID, 'ParameterValue', $contextID) + Get-GitHubContext -ListAvailable -Verbose:$false | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, 'ParameterValue', $_.Name) } } diff --git a/src/formats/GitHubContext.Format.ps1xml b/src/formats/GitHubContext.Format.ps1xml index 14fef32a3..638e3d673 100644 --- a/src/formats/GitHubContext.Format.ps1xml +++ b/src/formats/GitHubContext.Format.ps1xml @@ -10,10 +10,7 @@ - - - - + @@ -24,21 +21,12 @@ - - - - - - - UserName - - - HostName + Name AuthType @@ -49,12 +37,6 @@ TokenExpirationDate - - Owner - - - Repo - diff --git a/src/functions/private/Apps/Get-GitHubAppByName.ps1 b/src/functions/private/Apps/Get-GitHubAppByName.ps1 new file mode 100644 index 000000000..83ce43566 --- /dev/null +++ b/src/functions/private/Apps/Get-GitHubAppByName.ps1 @@ -0,0 +1,44 @@ +filter Get-GitHubAppByName { + <# + .SYNOPSIS + Get an app + + .DESCRIPTION + Gets a single GitHub App using the app's slug. + + .EXAMPLE + Get-GitHubAppByName -AppSlug 'github-actions' + + Gets the GitHub App with the slug 'github-actions'. + + .NOTES + [Get an app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-app) + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # The AppSlug is just the URL-friendly name of a GitHub App. + # You can find this on the settings page for your GitHub App (e.g., https://github.com/settings/apps/). + # Example: 'github-actions' + [Parameter(Mandatory)] + [Alias('Name')] + [string] $AppSlug, + + # The context to run the command in. Used to get the details for the API call. + # Can be either a string or a GitHubContext object. + [Parameter()] + [object] $Context = (Get-GitHubContext) + ) + + $Context = Resolve-GitHubContext -Context $Context + + $inputObject = @{ + Context = $Context + APIEndpoint = "/apps/$AppSlug" + Method = 'GET' + } + + Invoke-GitHubAPI @inputObject | ForEach-Object { + Write-Output $_.Response + } +} diff --git a/src/functions/private/Apps/Get-GitHubAuthenticatedApp.ps1 b/src/functions/private/Apps/Get-GitHubAuthenticatedApp.ps1 new file mode 100644 index 000000000..cf0fb44bd --- /dev/null +++ b/src/functions/private/Apps/Get-GitHubAuthenticatedApp.ps1 @@ -0,0 +1,43 @@ +filter Get-GitHubAuthenticatedApp { + <# + .SYNOPSIS + Get the authenticated app + + .DESCRIPTION + Returns the GitHub App associated with the authentication credentials used. To see how many app installations are associated with this + GitHub App, see the `installations_count` in the response. For more details about your app's installations, see the + "[List installations for the authenticated app](https://docs.github.com/rest/apps/apps#list-installations-for-the-authenticated-app)" + endpoint. + + You must use a [JWT](https://docs.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app) + to access this endpoint. + + .EXAMPLE + Get-GitHubAuthenticatedApp + + Get the authenticated app. + + .NOTES + [Get the authenticated app](https://docs.github.com/rest/apps/apps#get-an-app) + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # The context to run the command in. Used to get the details for the API call. + # Can be either a string or a GitHubContext object. + [Parameter()] + [object] $Context = (Get-GitHubContext) + ) + + $Context = Resolve-GitHubContext -Context $Context + + $inputObject = @{ + Context = $Context + APIEndpoint = '/app' + Method = 'GET' + } + + Invoke-GitHubAPI @inputObject | ForEach-Object { + Write-Output $_.Response + } +} diff --git a/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 b/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 index cab5a2a0a..716846345 100644 --- a/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 +++ b/src/functions/private/Auth/Context/Resolve-GitHubContext.ps1 @@ -1,11 +1,14 @@ -function Resolve-GitHubContext { +filter Resolve-GitHubContext { <# .SYNOPSIS Resolves the context into a GitHubContext object. .DESCRIPTION This function resolves the context into a GitHubContext object. - It can take both the + If the context is already a GitHubContext object, it will return the object. + If the context is a string, it will get the context details and return a GitHubContext object. + + If the context is a App, it will look at the available contexts and return the one that matches the scope of the command being run. .EXAMPLE $Context = Resolve-GitHubContext -Context 'github.com/Octocat' @@ -22,27 +25,44 @@ param( # The context to resolve into an object. Used to get the details for the API call. # Can be either a string or a GitHubContext object. - [Parameter()] + [Parameter(ValueFromPipeline)] [object] $Context = (Get-GitHubContext) ) - if ($Context -is [GitHubContext]) { - return $Context + begin { + $commandName = $MyInvocation.MyCommand.Name + Write-Verbose "[$commandName] - Start" + Write-Verbose 'Context:' + $Context | Out-String -Stream | ForEach-Object { Write-Verbose $_ } } - if ([string]::IsNullOrEmpty($Context)) { - throw "No contexts has been specified. Please provide a context or log in using 'Connect-GitHub'." - } + process { + if ($Context -is [string]) { + $contextName = $Context + Write-Debug "Getting context: [$contextName]" + $Context = Get-GitHubContext -Context $contextName + } - if ($Context -is [string]) { - $contextName = $Context - Write-Debug "Getting context: [$contextName]" - $Context = Get-GitHubContext -Context $contextName - } + if (-not $Context) { + throw "Context [$contextName] not found. Please provide a valid context or log in using 'Connect-GitHub'." + } - if (-not $Context) { - throw "Context [$contextName] not found. Please provide a valid context or log in using 'Connect-GitHub'." + switch ($Context.Type) { + 'App' { + $availableContexts = Get-GitHubContext -ListAvailable | + Where-Object { $_.Type -eq 'Installation' -and $_.ClientID -eq $Context.ClientID } + $params = Get-FunctionParameter -Scope 2 + Write-Verbose 'Resolving parameters used in called function' + Write-Verbose ($params | Out-String) + if ($params.Keys -in 'Owner', 'Organization') { + $Context = $availableContexts | Where-Object { $_.Owner -eq $params.Owner } + } + } + } } - return $Context + end { + Write-Verbose "[$commandName] - End" + Write-Output $Context + } } diff --git a/src/functions/private/Utilities/PowerShell/Get-FunctionParameter.ps1 b/src/functions/private/Utilities/PowerShell/Get-FunctionParameter.ps1 new file mode 100644 index 000000000..2adb448de --- /dev/null +++ b/src/functions/private/Utilities/PowerShell/Get-FunctionParameter.ps1 @@ -0,0 +1,75 @@ +function Get-FunctionParameter { + <# + .SYNOPSIS + Get the parameters and their final value in a function. + + .DESCRIPTION + This function retrieves the parameters and their final value in a function. + If a parameter is provided, it will retrieve the provided value. + If a parameter is not provided, it will attempt to retrieve the default value. + + .EXAMPLE + Get-FunctionParameter + + This will return all the parameters and their final value in the current function. + + .EXAMPLE + Get-FunctionParameter -IncludeCommonParameters + + This will return all the parameters and their final value in the current function, including common parameters. + + .EXAMPLE + Get-FunctionParameter -Scope 2 + + This will return all the parameters and their final value in the grandparent function. + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # Include common parameters in the output. + [Parameter()] + [switch] $IncludeCommonParameters, + + # The function to get the parameters for. + # Default is the calling scope (0). + # Scopes are based on nesting levels: + # 0 - Current scope + # 1 - Parent scope + # 2 - Grandparent scope + [Parameter()] + [int] $Scope = 0 + ) + + $Scope++ + + $commonParameters = @( + 'ProgressAction', 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', + 'OutVariable', 'OutBuffer', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable', 'WhatIf', + 'Confirm' + ) + + $InvocationInfo = (Get-Variable -Name MyInvocation -Scope $Scope -ErrorAction Stop).Value + $boundParameters = $InvocationInfo.BoundParameters + $allParameters = @{} + $parameters = $InvocationInfo.MyCommand.Parameters + + foreach ($paramName in $parameters.Keys) { + if (-not $IncludeCommonParameters -and $paramName -in $commonParameters) { + continue + } + if ($boundParameters.ContainsKey($paramName)) { + # Use the explicitly provided value + $allParameters[$paramName] = $boundParameters[$paramName] + } else { + # Attempt to retrieve the default value by invoking it + try { + $defaultValue = (Get-Variable -Name $paramName -Scope $Scope -ErrorAction SilentlyContinue).Value + } catch { + $defaultValue = $null + } + $allParameters[$paramName] = $defaultValue + } + } + + [pscustomobject]$allParameters +} diff --git a/src/functions/public/API/Invoke-GitHubAPI.ps1 b/src/functions/public/API/Invoke-GitHubAPI.ps1 index baec6bf7a..5062b0b3d 100644 --- a/src/functions/public/API/Invoke-GitHubAPI.ps1 +++ b/src/functions/public/API/Invoke-GitHubAPI.ps1 @@ -84,33 +84,19 @@ Write-Debug 'Invoking GitHub API...' Write-Debug 'Parameters:' - $PSBoundParameters.GetEnumerator() | ForEach-Object { - Write-Debug " - $($_.Key): $($_.Value)" - } + Get-FunctionParameter | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ } + Write-Debug 'Parent function parameters:' + Get-FunctionParameter -Scope 1 | Format-List | Out-String -Stream | ForEach-Object { Write-Debug $_ } $Context = Resolve-GitHubContext -Context $Context + $Token = $Context.Token + Write-Debug "Token : [$Token]" if ([string]::IsNullOrEmpty($TokenType)) { $TokenType = $Context.TokenType } Write-Debug "TokenType : [$($Context.TokenType)]" - switch ($TokenType) { - 'ghu' { - if (Test-GitHubAccessTokenRefreshRequired -Context $Context) { - # TODO: Ensure it can pass the context object, and have it update the context object - # TODO: Should it return the new context with a -PassThru parameter? - # $Context = Update-GitHubUserAccessToken -Context $Context - Update-GitHubUserAccessToken -Context $Context - $Token = (Get-GitHubContextSetting -Name 'Token' -Context $Context) - } - } - 'PEM' { - $JWT = Get-GitHubAppJSONWebToken -ClientId $Context.ClientID -PrivateKey $Context.Token - $Token = $JWT.Token - } - } - if ([string]::IsNullOrEmpty($ApiBaseUri)) { $ApiBaseUri = $Context.ApiBaseUri } @@ -121,15 +107,18 @@ } Write-Debug "ApiVersion : [$($Context.ApiVersion)]" - if ([string]::IsNullOrEmpty($TokenType)) { + switch ($TokenType) { + 'ghu' { + if (Test-GitHubAccessTokenRefreshRequired -Context $Context) { + $Token = Update-GitHubUserAccessToken -Context $Context -PassThru + } + } + 'PEM' { + $JWT = Get-GitHubAppJSONWebToken -ClientId $Context.ClientID -PrivateKey $Token + $Token = $JWT.Token + } } - Write-Debug "TokenType : [$($Context.TokenType)]" - - if ([string]::IsNullOrEmpty($Token)) { - $Token = $Context.Token - } - Write-Debug "Token : [$($Context.Token)]" $headers = @{ Accept = $Accept diff --git a/src/functions/public/Apps/GitHub Apps/Get-GitHubApp.ps1 b/src/functions/public/Apps/GitHub Apps/Get-GitHubApp.ps1 index 843995835..9055ec5ee 100644 --- a/src/functions/public/Apps/GitHub Apps/Get-GitHubApp.ps1 +++ b/src/functions/public/Apps/GitHub Apps/Get-GitHubApp.ps1 @@ -1,28 +1,38 @@ filter Get-GitHubApp { <# .SYNOPSIS - Get the authenticated app + Get the authenticated app or a specific app by its slug. .DESCRIPTION - Returns the GitHub App associated with the authentication credentials used. To see how many app installations are associated with this - GitHub App, see the `installations_count` in the response. For more details about your app's installations, see the - "[List installations for the authenticated app](https://docs.github.com/rest/apps/apps#list-installations-for-the-authenticated-app)" - endpoint. - - You must use a [JWT](https://docs.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app) - to access this endpoint. + Returns a GitHub App associated with the authentication credentials used or the provided app-slug. .EXAMPLE Get-GitHubApp Get the authenticated app. + .EXAMPLE + Get-GitHubApp -AppSlug 'github-actions' + + Get the GitHub App with the slug 'github-actions'. + .NOTES + [Get an app](https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-an-app) [Get the authenticated app | GitHub Docs](https://docs.github.com/rest/apps/apps#get-the-authenticated-app) #> [OutputType([pscustomobject])] - [CmdletBinding()] + [CmdletBinding(DefaultParameterSetName = '__AllParameterSets')] param( + # The AppSlug is just the URL-friendly name of a GitHub App. + # You can find this on the settings page for your GitHub App (e.g., ). + # Example: 'github-actions' + [Parameter( + Mandatory, + ParameterSetName = 'BySlug' + )] + [Alias('Name')] + [string] $AppSlug, + # The context to run the command in. Used to get the details for the API call. # Can be either a string or a GitHubContext object. [Parameter()] @@ -31,13 +41,12 @@ $Context = Resolve-GitHubContext -Context $Context - $inputObject = @{ - Context = $Context - APIEndpoint = '/app' - Method = 'GET' - } - - Invoke-GitHubAPI @inputObject | ForEach-Object { - Write-Output $_.Response + switch ($PSCmdlet.ParameterSetName) { + 'BySlug' { + Get-GitHubAppByName -AppSlug $AppSlug -Context $Context + } + default { + Get-GitHubAuthenticatedApp -Context $Context + } } } diff --git a/src/functions/public/Apps/GitHub Apps/New-GitHubAppInstallationAccessToken.ps1 b/src/functions/public/Apps/GitHub Apps/New-GitHubAppInstallationAccessToken.ps1 index 8a0613de8..d4d3667e3 100644 --- a/src/functions/public/Apps/GitHub Apps/New-GitHubAppInstallationAccessToken.ps1 +++ b/src/functions/public/Apps/GitHub Apps/New-GitHubAppInstallationAccessToken.ps1 @@ -45,6 +45,8 @@ 'PSUseShouldProcessForStateChangingFunctions', '', Justification = 'No state is changed.' )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The tokens are recieved as clear text. Mitigating exposure by removing variables and performing garbage collection.')] [CmdletBinding()] param( # The unique identifier of the installation. @@ -72,6 +74,11 @@ } Invoke-GitHubAPI @inputObject | ForEach-Object { - Write-Output $_.Response + [pscustomobject]@{ + Token = $_.Response.token | ConvertTo-SecureString -AsPlainText -Force + ExpiresAt = $_.Response.expires_at.ToLocalTime() + Permissions = $_.Response.permissions + RepositorySelection = $_.Response.repository_selection + } } } diff --git a/src/functions/public/Auth/Assert-GitHubContext.ps1 b/src/functions/public/Auth/Assert-GitHubContext.ps1 new file mode 100644 index 000000000..e53b67ae0 --- /dev/null +++ b/src/functions/public/Auth/Assert-GitHubContext.ps1 @@ -0,0 +1,33 @@ +filter Assert-GitHubContext { + <# + .SYNOPSIS + Check if the context meets the requirements for the command. + + .DESCRIPTION + This function checks if the context meets the requirements for the command. + If the context does not meet the requirements, an error is thrown. + + .EXAMPLE + Assert-GitHubContext -Context 'github.com/Octocat' -TokenType 'App' + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The context to run the command in. + [Parameter( + Mandatory, + ValueFromPipeline + )] + [GitHubContext] $Context, + + # The required authtypes for the command. + [Parameter(Mandatory)] + [string[]] $AuthType + ) + + $command = (Get-PSCallStack)[1].Command + + if ($Context.AuthType -notin $AuthType) { + throw "The context '$($Context.Name)' does not match the required AuthTypes [$AuthType] for [$command]." + } +} diff --git a/src/functions/public/Auth/Connect-GitHubApp.ps1 b/src/functions/public/Auth/Connect-GitHubApp.ps1 new file mode 100644 index 000000000..d541a2d99 --- /dev/null +++ b/src/functions/public/Auth/Connect-GitHubApp.ps1 @@ -0,0 +1,166 @@ +function Connect-GitHubApp { + <# + .SYNOPSIS + Connects to GitHub as a installation using a GitHub App. + + .DESCRIPTION + Connects to GitHub using a GitHub App to generate installation access tokens and create contexts for targets. + + Available target types: + - User + - Organization + - Enterprise + + .EXAMPLE + Connect-GitHubApp + + Connects to GitHub as all available targets using the logged in GitHub App. + + .EXAMPLE + Connect-GitHubApp -User 'octocat' + + Connects to GitHub as the user 'octocat' using the logged in GitHub App. + + .EXAMPLE + Connect-GitHubApp -Organization 'psmodule' + + Connects to GitHub as the organization 'psmodule' using the logged in GitHub App. + + .EXAMPLE + Connect-GitHubApp -Enterprise 'msx' + + Connects to GitHub as the enterprise 'msx' using the logged in GitHub App. + + .NOTES + [Authenticating to the REST API](https://docs.github.com/rest/overview/other-authentication-methods#authenticating-for-saml-sso) + #> + [OutputType([void])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Is the CLI part of the module.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', + Justification = 'The tokens are recieved as clear text. Mitigating exposure by removing variables and performing garbage collection.')] + [CmdletBinding(DefaultParameterSetName = '__AllParameterSets')] + param( + # The user account to connect to. + [Parameter( + Mandatory, + ParameterSetName = 'User' + )] + [string] $User, + + # The organization to connect to. + [Parameter( + Mandatory, + ParameterSetName = 'Organization' + )] + [string] $Organization, + + # The enterprise to connect to. + [Parameter( + Mandatory, + ParameterSetName = 'Enterprise' + )] + [string] $Enterprise, + + # The context to run the command in. Used to get the details for the API call. + # Can be either a string or a GitHubContext object. + [Parameter()] + [object] $Context = (Get-GitHubContext) + ) + + $commandName = $MyInvocation.MyCommand.Name + Write-Verbose "[$commandName] - Start" + + $Context = $Context | Resolve-GitHubContext + $Context | Assert-GitHubContext -AuthType 'App' + + try { + $defaultContextData = @{ + ApiBaseUri = $Context.ApiBaseUri + ApiVersion = $Context.ApiVersion + HostName = $Context.HostName + ClientID = $Context.ClientID + AuthType = 'IAT' + TokenType = 'ghs' + } + + $installations = Get-GitHubAppInstallation + switch ($PSCmdlet.ParameterSetName) { + 'User' { + $installations = $installations | Where-Object { $_.target_type -eq 'User' -and $_.account.login -in $User } + } + 'Organization' { + $installations = $installations | Where-Object { $_.target_type -eq 'Organization' -and $_.account.login -in $Organization } + } + 'Enterprise' { + $installations = $installations | Where-Object { $_.target_type -eq 'Enterprise' -and $_.account.slug -in $Enterprise } + } + } + + $installations | ForEach-Object { + $installation = $_ + $contextParams = @{} + $defaultContextData.Clone() + $token = New-GitHubAppInstallationAccessToken -InstallationID $installation.id + $contextParams += @{ + InstallationID = $installation.id + Token = $token.Token + TokenExpirationDate = $token.ExpiresAt + } + switch ($installation.target_type) { + 'User' { + $contextParams['Owner'] = $installation.account.login + } + 'Organization' { + $contextParams['Owner'] = $installation.account.login + } + 'Enterprise' { + $contextParams['Enterprise'] = $installation.account.slug + } + } + Write-Verbose 'Logging in using an installation access token...' + Write-Verbose ($contextParams | Format-Table | Out-String) + $tmpContext = Set-GitHubContext @contextParams -PassThru + Write-Verbose ($tmpContext | Format-List | Out-String) + if (-not $Silent) { + $name = $tmpContext.name + Write-Host "Connected $name" + } + Remove-Variable -Name tmpContext -ErrorAction SilentlyContinue + Remove-Variable -Name token -ErrorAction SilentlyContinue + } + } catch { + Write-Error $_ + Write-Error (Get-PSCallStack | Format-Table | Out-String) + throw 'Failed to connect to GitHub using a GitHub App.' + } finally { + [System.GC]::Collect() + } + Write-Verbose "[$commandName] - End" +} + +Register-ArgumentCompleter -CommandName Connect-GitHubApp -ParameterName User -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter + + Get-GitHubAppInstallation -Verbose:$false | Where-Object { $_.target_type -eq 'User' -and $_.account.login -like "$wordToComplete*" } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.account.login, $_.account.login, 'ParameterValue', $_.account.login) + } +} +Register-ArgumentCompleter -CommandName Connect-GitHubApp -ParameterName Organization -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter + + Get-GitHubAppInstallation -Verbose:$false | Where-Object { $_.target_type -eq 'Organization' -and $_.account.login -like "$wordToComplete*" } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.account.login, $_.account.login, 'ParameterValue', $_.account.login) + } +} +Register-ArgumentCompleter -CommandName Connect-GitHubApp -ParameterName Enterprise -ScriptBlock { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + $null = $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter + + Get-GitHubAppInstallation -Verbose:$false | Where-Object { $_.target_type -eq 'Enterprise' -and $_.account.slug -like "$wordToComplete*" } | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.account.slug, $_.account.slug, 'ParameterValue', $_.account.slug) + } +} diff --git a/src/functions/public/Auth/Context/Set-GitHubContext.ps1 b/src/functions/public/Auth/Context/Set-GitHubContext.ps1 index 90e98bcbb..73e88f756 100644 --- a/src/functions/public/Auth/Context/Set-GitHubContext.ps1 +++ b/src/functions/public/Auth/Context/Set-GitHubContext.ps1 @@ -57,6 +57,10 @@ function Set-GitHubContext { [Parameter(Mandatory)] [string] $HostName, + # Set the installation ID. + [Parameter()] + [int] $InstallationID, + # Set the enterprise name for the context. [Parameter()] [string] $Enterprise, @@ -102,6 +106,7 @@ function Set-GitHubContext { AuthClientID = $AuthClientID # Client ID for UAT AuthType = $AuthType # UAT / PAT / App / IAT ClientID = $ClientID # Client ID for GitHub Apps + InstallationID = $InstallationID # Installation ID DeviceFlowType = $DeviceFlowType # GitHubApp / OAuthApp HostName = $HostName # github.com / msx.ghe.com / github.local Enterprise = $Enterprise # Enterprise name @@ -125,12 +130,21 @@ function Set-GitHubContext { switch -Regex ($AuthType) { 'PAT|UAT|IAT' { $viewer = Get-GitHubViewer -Context $tempContext - $contextName = "$HostName/$($viewer.login)" - $context['Name'] = $contextName - $context['Username'] = $viewer.login + $login = $viewer.login + $context['Username'] = $login $context['NodeID'] = $viewer.id $context['DatabaseID'] = ($viewer.databaseId).ToString() } + 'PAT|UAT' { + $contextName = "$HostName/$login" + $context['Name'] = $contextName + $context['Type'] = 'User' + } + 'IAT' { + $contextName = "$HostName/$login/$Owner" -Replace '\[bot\]' + $context['Name'] = $contextName + $context['Type'] = 'Installation' + } 'App' { $app = Get-GitHubApp -Context $tempContext $contextName = "$HostName/$($app.slug)" @@ -138,12 +152,13 @@ function Set-GitHubContext { $context['Username'] = $app.slug $context['NodeID'] = $app.node_id $context['DatabaseID'] = $app.id + $context['Type'] = 'App' } default { throw 'Failed to get info on the context. Unknown logon type.' } } - Write-Verbose "Found user with username: [$contextName]" + Write-Verbose "Found [$($context['Type']) with login: [$contextName]" if ($PSCmdlet.ShouldProcess('Context', 'Set')) { Set-Context -ID "$($script:Config.Name)/$contextName" -Context $context diff --git a/src/functions/public/Auth/Update-GitHubUserAccessToken.ps1 b/src/functions/public/Auth/Update-GitHubUserAccessToken.ps1 index 93698afc2..36a71abeb 100644 --- a/src/functions/public/Auth/Update-GitHubUserAccessToken.ps1 +++ b/src/functions/public/Auth/Update-GitHubUserAccessToken.ps1 @@ -19,6 +19,7 @@ .NOTES [Refreshing user access tokens](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens) #> + [OutputType([securestring])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '', Justification = 'Long links for documentation.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Is the CLI part of the module.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Justification = 'The tokens are recieved as clear text. Mitigating exposure by removing variables and performing garbage collection.')] @@ -27,7 +28,11 @@ # The context to run the command in. Used to get the details for the API call. # Can be either a string or a GitHubContext object. [Parameter()] - [object] $Context = (Get-GitHubContext) + [object] $Context = (Get-GitHubContext), + + # Return the new access token. + [Parameter()] + [switch] $PassThru ) $Context = Resolve-GitHubContext -Context $Context @@ -80,4 +85,8 @@ if ($PSCmdlet.ShouldProcess('Access token', 'Update/refresh')) { Set-GitHubContextSetting @settings -Context $Context } + + if ($PassThru) { + $settings['Token'] + } } diff --git a/tests/GitHub.Tests.ps1 b/tests/GitHub.Tests.ps1 index ef0cf3bc9..c917007c0 100644 --- a/tests/GitHub.Tests.ps1 +++ b/tests/GitHub.Tests.ps1 @@ -7,6 +7,10 @@ param() Describe 'GitHub' { Context 'Auth' { + BeforeAll { + Disconnect-GitHubAccount -Context 'github.com/github-actions[bot]' -Silent + } + It 'Can connect and disconnect without parameters in GitHubActions' { { Connect-GitHubAccount } | Should -Not -Throw { Disconnect-GitHubAccount } | Should -Not -Throw @@ -27,7 +31,7 @@ Describe 'GitHub' { { Connect-GitHubAccount -Token $env:TEST_PAT } | Should -Not -Throw { Connect-GitHubAccount } | Should -Not -Throw # Logs on with GitHub Actions' token (Get-GitHubContext -ListAvailable).Count | Should -Be 2 - Get-GitHubConfig -Name 'DefaultContext' | Should -Be 'github.com/github-actions[bot]' + Get-GitHubConfig -Name 'DefaultContext' | Should -Be 'github.com/github-actions/PSModule' Write-Verbose (Get-GitHubContext | Out-String) -Verbose } @@ -49,7 +53,7 @@ Describe 'GitHub' { } It 'Can disconnect a specific context' { - { Disconnect-GitHubAccount -Context 'github.com/github-actions[bot]' -Silent } | Should -Not -Throw + { Disconnect-GitHubAccount -Context 'github.com/github-actions/PSModule' -Silent } | Should -Not -Throw (Get-GitHubContext -ListAvailable).Count | Should -Be 2 Connect-GitHubAccount Connect-GitHubAccount -ClientID $env:TEST_APP_CLIENT_ID -PrivateKey $env:TEST_APP_PRIVATE_KEY @@ -62,9 +66,19 @@ Describe 'GitHub' { $app | Should -Not -BeNullOrEmpty } + It 'Can connect to a GitHub App Installation' { + { Connect-GitHubApp -Organization 'PSModule' } | Should -Not -Throw + Write-Verbose (Get-GitHubContext | Out-String) -Verbose + } + + It 'Can connect to all GitHub App Installations' { + { Connect-GitHubApp } | Should -Not -Throw + Write-Verbose (Get-GitHubContext | Out-String) -Verbose + } + It 'Can swap context to another' { - { Set-GitHubDefaultContext -Context 'github.com/github-actions[bot]' } | Should -Not -Throw - Get-GitHubConfig -Name 'DefaultContext' | Should -Be 'github.com/github-actions[bot]' + { Set-GitHubDefaultContext -Context 'github.com/github-actions/PSModule' } | Should -Not -Throw + Get-GitHubConfig -Name 'DefaultContext' | Should -Be 'github.com/github-actions/PSModule' } # It 'Can be called with a GitHub App Installation Access Token' { diff --git a/tools/utilities/GitHubAPI.ps1 b/tools/utilities/GitHubAPI.ps1 index d07d24992..e7a06f986 100644 --- a/tools/utilities/GitHubAPI.ps1 +++ b/tools/utilities/GitHubAPI.ps1 @@ -21,7 +21,7 @@ $response = Invoke-RestMethod -Uri $APIDocURI -Method Get # @{n = 'PUT'; e = { (($_.value.psobject.Properties.Name) -contains 'PUT') } }, ` # @{n = 'PATCH'; e = { (($_.value.psobject.Properties.Name) -contains 'PATCH') } } | Format-Table -$path = '/app/hook/deliveries/{delivery_id}' +$path = '/apps/{app_slug}' $method = 'get' $response.paths.$path.$method $response.paths.$path.$method.tags | clip # -> Namespace/foldername