diff --git a/.github/workflows/Test.GitHub.yml b/.github/workflows/Test.GitHub.yml new file mode 100644 index 000000000..a88266690 --- /dev/null +++ b/.github/workflows/Test.GitHub.yml @@ -0,0 +1,81 @@ +name: Test [GitHub] + +on: + workflow_dispatch: + +permissions: write-all + +jobs: + TestGitHub: + name: Test GitHub + if: always() + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + env: + GH_TOKEN: ${{ github.token }} # Used for GitHub CLI authentication + steps: + - name: Test Authentication using auto PAT + if: always() + shell: pwsh + run: | + Write-Output '::group::[Debug info] - Environment variables' + $env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY.Split('/')[1] + Get-ChildItem Env: + Write-Output '::endgroup::' + + Write-Output '::group::[Debug info] - File structure' + Write-Verbose "Current directory: $((Get-Location).Path)" -Verbose + Write-Verbose "------------------------------------" -Verbose + Write-Verbose "Current directory content:" -Verbose + Get-ChildItem -Path . -Recurse | Select-Object -ExpandProperty FullName | Sort-Object + Write-Output '::endgroup::' + + Write-Output '::group::Install-Module -Name GitHub -Verbose -Force' + Install-Module -Name GitHub -Verbose -Force + Write-Output '::endgroup::' + + Write-Output '::group::Get-GitHubConfig' + Get-GitHubConfig + Write-Output '::endgroup::' + + Write-Output '::group::Get-GitHubWorkflow -Repo $env:GITHUB_REPOSITORY_NAME -Owner $env:GITHUB_REPOSITORY_OWNER -Verbose' + Get-GitHubWorkflow -Repo $env:GITHUB_REPOSITORY_NAME -Owner $env:GITHUB_REPOSITORY_OWNER -Verbose + Write-Output '::endgroup::' + + - name: Test Authentication using Specific PAT + if: always() + shell: pwsh + run: | + Write-Output '::group::[Debug info] - Environment variables' + $env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY.Split('/')[1] + Get-ChildItem Env: + Write-Output '::endgroup::' + + Write-Output '::group::[Debug info] - File structure' + Write-Verbose "Current directory: $((Get-Location).Path)" -Verbose + Write-Verbose "------------------------------------" -Verbose + Write-Verbose "Current directory content:" -Verbose + Get-ChildItem -Path . -Recurse | Select-Object -ExpandProperty FullName | Sort-Object + Write-Output '::endgroup::' + + Write-Output '::group::Install-Module -Name GitHub -Verbose -Force' + Install-Module -Name GitHub -Verbose -Force + Write-Output '::endgroup::' + + Write-Output '::group::Get-GitHubConfig' + Get-GitHubConfig + Write-Output '::endgroup::' + + Write-Output '::group::Connect-GitHubAccount -AccessToken $env:GH_TOKEN -Verbose' + Connect-GitHubAccount -AccessToken $env:GH_TOKEN -Verbose + Write-Output '::endgroup::' + + Write-Output '::group::Get-GitHubConfig' + Get-GitHubConfig + Write-Output '::endgroup::' + + Write-Output '::group::Get-GitHubWorkflow -Repo $env:GITHUB_REPOSITORY_NAME -Owner $env:GITHUB_REPOSITORY_OWNER -Verbose' + Get-GitHubWorkflow -Repo $env:GITHUB_REPOSITORY_NAME -Owner $env:GITHUB_REPOSITORY_OWNER -Verbose + Write-Output '::endgroup::' diff --git a/README.md b/README.md index f114995de..293c5a4aa 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,34 @@ To dive into the world of GitHub automation with PowerShell, follow these steps: 1. **Installation**: Download and install the GitHub PowerShell module from the provided link or the PowerShell Gallery. -2. **Authentication**: Authenticate using your GitHub credentials or access tokens to begin executing commands. + ```powershell + Install-Module -Name GitHub -Force -AllowClobber + ``` -3. **Command Exploration**: Familiarize yourself with the available cmdlets using the module's comprehensive documentation or inline help. +1. **Authentication**: Authenticate using your GitHub credentials or access tokens to begin executing commands. -4. **Sample Scripts**: Check out sample scripts and usage patterns to jumpstart your automation tasks on GitHub. +Logging in using device flow: +```powershell +Connect-GitHubAccount + +Please visit: https://github.com/login/device +and enter code: ABCD-1234 +Successfully authenticated! +``` + +Logging in using PAT token: +```powershell +>_ Connect-GitHubAccount -AccessToken 'ghp_abcdefghklmnopqrstuvwxyz123456789123' +>_ +``` + +2. **Command Exploration**: Familiarize yourself with the available cmdlets using the module's comprehensive documentation or inline help. + + ```powershell + Get-Command -Module GitHub + ``` + +3. **Sample Scripts**: Check out sample scripts and usage patterns to jumpstart your automation tasks on GitHub. ## More Information & Resources diff --git a/media/github.png b/media/github.png new file mode 100644 index 000000000..9629d048c Binary files /dev/null and b/media/github.png differ diff --git a/src/GitHub/GitHub.ps1 b/src/GitHub/GitHub.ps1 new file mode 100644 index 000000000..6122c7dd3 --- /dev/null +++ b/src/GitHub/GitHub.ps1 @@ -0,0 +1,14 @@ +Write-Verbose "Initializing GitHub module..." -Verbose + +$script:Config = $script:ConfigTemplate | ConvertTo-Json -Depth 100 | ConvertFrom-Json +Initialize-SecretVault -Name $script:SecretVault.Name -Type $script:SecretVault.Type +Restore-GitHubConfig + +if (-not [string]::IsNullOrEmpty($env:GH_TOKEN)) { + Write-Verbose 'Logging on using GH_TOKEN' + Connect-GitHubAccount -AccessToken $env:GH_TOKEN +} +if (-not [string]::IsNullOrEmpty($env:GITHUB_TOKEN)) { + Write-Verbose 'Logging on using GITHUB_TOKEN' + Connect-GitHubAccount -AccessToken $env:GITHUB_TOKEN +} diff --git a/src/GitHub/GitHub.psm1 b/src/GitHub/GitHub.psm1 index 14391165c..bbac83a75 100644 --- a/src/GitHub/GitHub.psm1 +++ b/src/GitHub/GitHub.psm1 @@ -1,32 +1,53 @@ [Cmdletbinding()] param() -$sciptName = $MyInvocation.MyCommand.Name +$scriptName = $MyInvocation.MyCommand.Name +Write-Verbose "[$scriptName] - Importing module" -Write-Verbose "[$sciptName] Importing subcomponents" -$folders = 'classes', 'private', 'public' -# Import everything in these folders +#region - Importing data files +Write-Verbose "[$scriptName] - [data] - Processing folder" +$dataFolder = (Join-Path $PSScriptRoot 'data') +Write-Verbose "[$scriptName] - [data] - [$dataFolder]" +Get-ChildItem -Path "$dataFolder" -Recurse -Force -Include '*.psd1' | ForEach-Object { + Write-Verbose "[$scriptName] - [data] - [$($_.Name)] - Importing data file" + New-Variable -Name $_.BaseName -Value (Import-PowerShellDataFile -Path $_.FullName) -Force + Write-Verbose "[$scriptName] - [data] - [$($_.Name)] - Done" +} +Write-Verbose "[$scriptName] - [data] - Done" +#endregion - Importing datas + +#region - Importing script files +$folders = 'init', 'classes', 'private', 'public' foreach ($folder in $folders) { - Write-Verbose "[$sciptName] - Processing folder [$folder]" + Write-Verbose "[$scriptName] - [$folder] - Processing folder" $folderPath = Join-Path -Path $PSScriptRoot -ChildPath $folder - Write-Verbose "[$sciptName] - [$folderPath]" if (Test-Path -Path $folderPath) { - Write-Verbose "[$sciptName] - [$folderPath] - Getting all files" - $files = $null - $files = Get-ChildItem -Path $folderPath -Include '*.ps1', '*.psm1' -Recurse - # dot source each file + $files = Get-ChildItem -Path $folderPath -Include '*.ps1', '*.psm1' -Recurse | Sort-Object -Property FullName foreach ($file in $files) { - Write-Verbose "[$sciptName] - [$folderPath] - [$($file.Name)] - Importing" - Import-Module $file - Write-Verbose "[$sciptName] - [$folderPath] - [$($file.Name)] - Done" + Write-Verbose "[$scriptName] - [$folder] - [$($file.Name)] - Importing script file" + Import-Module $file -Verbose:$false + Write-Verbose "[$scriptName] - [$folder] - [$($file.Name)] - Done" } } + Write-Verbose "[$scriptName] - [$folder] - Done" } +#endregion - Importing script files +#region - Importing root script files +Write-Verbose "[$scriptName] - [PSModuleRoot] - Processing folder" +Get-ChildItem -Path $PSScriptRoot -Filter '*.ps1' | ForEach-Object { + Write-Verbose "[$scriptName] - [PSModuleRoot] - [$($_.Name)] - Importing root script files" + Import-Module $_ -Verbose:$false + Write-Verbose "[$scriptName] - [PSModuleRoot] - [$($_.Name)] - Done" +} +Write-Verbose "[$scriptName] - [Root] - Done" +#endregion - Importing root script files + +#region Export module members $foldersToProcess = Get-ChildItem -Path $PSScriptRoot -Directory | Where-Object -Property Name -In $folders $moduleFiles = $foldersToProcess | Get-ChildItem -Include '*.ps1' -Recurse -File -Force $functions = $moduleFiles.BaseName -$Param = @{ +$param = @{ Function = $functions Variable = '' Cmdlet = '' @@ -35,4 +56,7 @@ $Param = @{ Write-Verbose 'Exporting module members' -Export-ModuleMember @Param +Export-ModuleMember @param +#endregion Export module members + +Write-Verbose "[$scriptName] - Done" diff --git a/src/GitHub/classes/Data/Config.ps1 b/src/GitHub/classes/Data/Config.ps1 deleted file mode 100644 index aba83cdc6..000000000 --- a/src/GitHub/classes/Data/Config.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -$script:ConfigTemplate = [pscustomobject]@{ - App = [pscustomobject]@{ - API = [pscustomobject]@{ - BaseURI = 'https://api.github.com' # $script:ConfigTemplate.App.API.BaseURI - Version = '2022-11-28' # $script:ConfigTemplate.App.API.Version - } - Defaults = [pscustomobject]@{} # $script:ConfigTemplate.App.Defaults - } -} -$script:Config = $script:ConfigTemplate diff --git a/src/GitHub/classes/Data/SecretVault.ps1 b/src/GitHub/classes/Data/SecretVault.ps1 deleted file mode 100644 index a56d58174..000000000 --- a/src/GitHub/classes/Data/SecretVault.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -$script:SecretVault = [pscustomobject]@{ - Name = 'GitHub' # $script:SecretVault.Name - Type = 'Microsoft.PowerShell.SecretStore' # $script:SecretVault.Type -} -$script:Secret = [pscustomobject]@{ - Name = 'Config' # $script:Secret.Name -} diff --git a/src/GitHub/data/Auth.psd1 b/src/GitHub/data/Auth.psd1 new file mode 100644 index 000000000..c1701ac68 --- /dev/null +++ b/src/GitHub/data/Auth.psd1 @@ -0,0 +1,8 @@ +@{ + GitHubApp = @{ + ClientID = 'Iv1.f26b61bc99e69405' # $script:Auth.GitHubApp.ClientID + } + OAuthApp = @{ + ClientID = '7204ae9b0580f2cb8288' # $script:Auth.OAuthApp.ClientID + } +} diff --git a/src/GitHub/data/SecretVault.psd1 b/src/GitHub/data/SecretVault.psd1 new file mode 100644 index 000000000..e119766f1 --- /dev/null +++ b/src/GitHub/data/SecretVault.psd1 @@ -0,0 +1,7 @@ +@{ + Name = 'GitHub' # $script:SecretVault.Name + Type = 'Microsoft.PowerShell.SecretStore' # $script:SecretVault.Type + Secret = @{ + Name = 'Config' # $script:SecretVault.Secret.Name + } +} diff --git a/src/GitHub/en_US/about_Auth.help.txt b/src/GitHub/en_US/about_Auth.help.txt new file mode 100644 index 000000000..d7bf1793f --- /dev/null +++ b/src/GitHub/en_US/about_Auth.help.txt @@ -0,0 +1,45 @@ +TOPIC + about_Auth + +SHORT DESCRIPTION + Describes the authentication methods provided in the PowerShell module for interacting with GitHub's REST API. + +LONG DESCRIPTION + This module provides several functions to manage authentication for GitHub's REST API. There are primarily two ways to authenticate: + + 1. GitHub Device Flow: This method prompts the user to visit a specific URL on GitHub where they must enter a user verification code. Once this is done, the module retrieves the necessary access tokens to make authenticated API requests. + + 2. Personal Access Token: The user can provide a Personal Access Token (PAT) to authenticate. This PAT allows the module to interact with the API on the user's behalf. The module can automatically use environment variables `GH_TOKEN` or `GITHUB_TOKEN` if they are present. + + The module also provides functionalities to refresh the access token and to disconnect or logout from the GitHub account. + +EXAMPLES + Example 1: + Connect-GitHubAccount + Connects to GitHub using the device flow login. You'll be prompted to visit a specific URL on GitHub and enter the provided user verification code. + + Example 2: + Connect-GitHubAccount -AccessToken 'ghp_####' + Connects to GitHub using a provided personal access token (PAT). + + Example 3: + Connect-GitHubAccount -Refresh + Refreshes the access token for continued session validity. + + Example 4: + Disconnect-GitHubAccount + Disconnects from GitHub and removes the current GitHub configuration. + + Example 5 (Automatic login using environment variables): + If either the `GH_TOKEN` or `GITHUB_TOKEN` environment variables are set, the module will automatically use them for authentication during module initialization. + +KEYWORDS + GitHub, Authentication, Device Flow, Personal Access Token, PowerShell, REST API + +SEE ALSO + For more information on the Device Flow visit: + - https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-cli-with-a-github-app + + For information about scopes and other authentication methods on GitHub: + - https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + - https://docs.github.com/en/rest/overview/other-authentication-methods#authenticating-for-saml-sso diff --git a/src/GitHub/en_US/about_Config.help.txt b/src/GitHub/en_US/about_Config.help.txt index 61b5602f0..315028b52 100644 --- a/src/GitHub/en_US/about_Config.help.txt +++ b/src/GitHub/en_US/about_Config.help.txt @@ -6,33 +6,50 @@ SHORT DESCRIPTION LONG DESCRIPTION The PowerShell Module provides a set of functions to manage the configuration related to the module. - This configuration is stored in a custom secret vault and can be accessed, modified, saved, and restored + The configuration is stored in a secret vault and can be accessed, modified, saved, and restored using the provided cmdlets. - Name: SecretVault - Path: \classes\Data\SecretVault.ps1 +DATA STRUCTURE - | Name | Type | Default Value | Description | - | --------------- | -------------- | ---------------------------------- | ----------------------------- | - | SecretVault | pscustomobject | {Name, Type} | | - | SecretVault.Name | string | 'GitHub' | The name of the secret vault. | - | SecretVault.Type | string | 'Microsoft.PowerShell.SecretStore' | The type of the secret vault. | - | Secret | pscustomobject | {Name} | | - | Secret.Name | string | 'Config' | The name of the secret. | + Name: SecretVault + Purpose: Hold static configuration data about the secret vault. + Path: \classes\Data\SecretVault.psd1 + | Name | Type | Static Value | Description | + | ---------------------------- | -------------- | ---------------------------------- | ----------------------------- | + | SecretVault | pscustomobject | {Name, Type, Secret} | | + | SecretVault.Name | string | 'GitHub' | The name of the secret vault. | + | SecretVault.Type | string | 'Microsoft.PowerShell.SecretStore' | The type of the secret vault. | + | SecretVault.Secret | pscustomobject | {Name} | | + | SecretVault.Secret.Name | string | 'Config' | The name of the secret. | Name: Config + Purpose: Hold the current configuration data. Path: \classes\Data\Config.ps1 - | Name | Type | Static Value | Description | - | --------------- | ----------------- | ------------------------ | ------------------------ | - | App | pscustomobject | {API, Defaults} | | - | App.API | pscustomobject | {BaseURI, Version} | | - | App.API.BaseURI | string | 'https://api.github.com' | The GitHub API Base URI. | - | App.API.Version | string | '2022-11-28' | The GitHub API version. | - | App.Defaults | pscustomobject | {} | | - - Functions provided in the module: + | Name | Type | Default Value | Description | + | ------------------------------------ | -------------- | ------------------------ | --------------------------------- | + | App | pscustomobject | | | + | App.API | pscustomobject | | | + | App.API.BaseURI | string | 'https://api.github.com' | The GitHub API Base URI. | + | App.API.Version | string | '2022-11-28' | The GitHub API version. | + | App.Defaults | pscustomobject | {} | | + | User | pscustomobject | | | + | User.Auth | pscustomobject | | | + | User.Auth.AccessToken | pscustomobject | | The access token. | + | User.Auth.AccessToken.Value | string | '' | The access token value. | + | User.Auth.AccessToken.ExpirationDate | datetime | [datetime]::MinValue | The access token expiration date. | + | User.Auth.ClientID | string | '' | The client ID. | + | User.Auth.Mode | string | '' | The authentication mode. | + | User.Auth.RefreshToken | pscustomobject | | The refresh token. | + | User.Auth.RefreshToken.Value | string | '' | The refresh token value. | + | User.Auth.RefreshToken.ExpirationDate| datetime | [datetime]::MinValue | The refresh token expiration date.| + | User.Auth.Scope | string | '' | The scope. | + | User.Defaults | pscustomobject | | | + | User.Defaults.Owner | string | '' | The default owner. | + | User.Defaults.Repo | string | '' | The default repository. | + +FUNCTIONS - Get-GitHubConfig: Fetches the current module configuration. - Reset-GitHubConfig: Resets all or specific sections to its default values. @@ -40,17 +57,19 @@ LONG DESCRIPTION - Save-GitHubConfig: Saves the current configuration to the secret vault. - Set-GitHubConfig: Allows setting specific elements of the configuration. - The configuration values are securely stored using the SecretManagement and SecretStore modules. - During the module import, the following steps are performed: - - Initialize the configuration store. - - Check for secret vault of type 'Microsoft.PowerShell.SecretStore'. - If not registered for the current user, its configuration will be reset to unattended mode. - - Check for secret vault with the name 'GitHub'. - If it does not exist, it will be created with current configuration. - If the user is already using the secret vault, the existing configuration will be kept. - - Restore saved configuration from the configuration store. - - Look for the 'GitHub' secret vault. - - Look for the secret called 'Config'. If it exists, restore the configuration from it into memory +CONFIGURATION + + The configuration values are securely stored using the SecretManagement and SecretStore modules. During the module import, the following steps are performed: + + 1. Initialize the configuration store. + - Check for secret vault of type 'Microsoft.PowerShell.SecretStore'. + If not registered for the current user, its configuration will be reset to unattended mode. + - Check for secret vault with the name 'GitHub'. + If it does not exist, it will be created with current configuration. + If the user is already using the secret vault, the existing configuration will be kept. + 2. Restore saved configuration from the configuration store. + - Look for the 'GitHub' secret vault. + - Look for the secret called 'Config'. If it exists, restore the configuration from it into memory. EXAMPLES @@ -85,12 +104,14 @@ EXAMPLES This command saves the current GitHub configuration to the secret vault. KEYWORDS + GitHub PowerShell SecretManagement SecretStore SEE ALSO + - For more information about SecretManagement and SecretStore: https://learn.microsoft.com/en-us/powershell/utility-modules/secretmanagement/overview?view=ps-modules - The GitHub repository of this module: diff --git a/src/GitHub/private/Auth/DeviceFlow/Check-GitHubAccessToken.ps1 b/src/GitHub/private/Auth/DeviceFlow/Check-GitHubAccessToken.ps1 new file mode 100644 index 000000000..1ef8db1af --- /dev/null +++ b/src/GitHub/private/Auth/DeviceFlow/Check-GitHubAccessToken.ps1 @@ -0,0 +1,12 @@ +function Check-GitHubAccessToken { + + [DateTime]$accessTokenExirationDate = $script:Config.User.Auth.AccessToken.ExpirationDate + $accessTokenValid = $accessTokenExirationDate -gt (Get-Date) + + if (-not $accessTokenValid) { + Write-Warning 'Your access token has expired. Refreshing it...' + Connect-GitHubAccount -Refresh + } + $TimeSpan = New-TimeSpan -Start (Get-Date) -End $accessTokenExirationDate + Write-Host "Your access token will expire in $($TimeSpan.Days)-$($TimeSpan.Hours):$($TimeSpan.Minutes):$($TimeSpan.Seconds)." +} diff --git a/src/GitHub/private/Auth/DeviceFlow/Invoke-GitHubDeviceFlowLogin.ps1 b/src/GitHub/private/Auth/DeviceFlow/Invoke-GitHubDeviceFlowLogin.ps1 new file mode 100644 index 000000000..93762f95c --- /dev/null +++ b/src/GitHub/private/Auth/DeviceFlow/Invoke-GitHubDeviceFlowLogin.ps1 @@ -0,0 +1,60 @@ +function Invoke-GitHubDeviceFlowLogin { + <# + .SYNOPSIS + Starts the GitHub Device Flow login process. + + .DESCRIPTION + Starts the GitHub Device Flow login process. This will prompt the user to visit a URL and enter a code. + + .EXAMPLE + Invoke-GitHubDeviceFlowLogin + + This will start the GitHub Device Flow login process. + The user gets prompted to visit a URL and enter a code. + + .NOTES + For more info about the Device Flow visit: + https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-cli-with-a-github-app + https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The Client ID of the GitHub App. + [Parameter(Mandatory)] + [string] $ClientID, + + # The scope of the access token, when using OAuth authentication. + # Provide the list of scopes as space-separated values. + # For more information on scopes visit: + # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + [Parameter()] + [string] $Scope, + + # The refresh token to use for re-authentication. + [Parameter()] + [string] $RefreshToken + ) + + do { + if ($RefreshToken) { + $tokenResponse = Wait-GitHubAccessToken -ClientID $ClientID -RefreshToken $RefreshToken + } else { + $deviceCodeResponse = Request-GitHubDeviceCode -ClientID $ClientID -Scope $Scope + + $deviceCode = $deviceCodeResponse.device_code + $interval = $deviceCodeResponse.interval + $userCode = $deviceCodeResponse.user_code + $verificationUri = $deviceCodeResponse.verification_uri + + Write-Host '! ' -ForegroundColor DarkYellow -NoNewline + Write-Host "We added the code to your clipboard: [$userCode]" + $userCode | Set-Clipboard + Read-Host 'Press Enter to open github.com in your browser...' + Start-Process $verificationUri + + $tokenResponse = Wait-GitHubAccessToken -DeviceCode $deviceCode -ClientID $ClientID -Interval $interval + } + } while ($tokenResponse.error) + $tokenResponse +} diff --git a/src/GitHub/private/Auth/DeviceFlow/Request-GitHubAccessToken.ps1 b/src/GitHub/private/Auth/DeviceFlow/Request-GitHubAccessToken.ps1 new file mode 100644 index 000000000..fda7406cb --- /dev/null +++ b/src/GitHub/private/Auth/DeviceFlow/Request-GitHubAccessToken.ps1 @@ -0,0 +1,75 @@ +function Request-GitHubAccessToken { + <# + .SYNOPSIS + Request a GitHub token using the Device Flow. + + .DESCRIPTION + Request a GitHub token using the Device Flow. + This will poll the GitHub API until the user has entered the code. + + .EXAMPLE + Request-GitHubAccessToken -DeviceCode $deviceCode -ClientID $ClientID + + This will poll the GitHub API until the user has entered the code. + + .NOTES + For more info about the Device Flow visit: + https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-cli-with-a-github-app + #> + [OutputType([PSCustomObject])] + [CmdletBinding(DefaultParameterSetName = 'DeviceFlow')] + param( + # The Client ID of the GitHub App. + [Parameter(Mandatory)] + [string] $ClientID, + + # The 'device_code' used to request the access token. + [Parameter( + Mandatory, + ParameterSetName = 'DeviceFlow' + )] + [string] $DeviceCode, + + # The refresh token used create a new access token. + [Parameter( + Mandatory, + ParameterSetName = 'RefreshToken' + )] + [string] $RefreshToken + ) + + $body = @{ + 'client_id' = $ClientID + } + + if ($PSBoundParameters.ContainsKey('RefreshToken')) { + $body += @{ + 'refresh_token' = $RefreshToken + 'grant_type' = 'refresh_token' + } + } + + if ($PSBoundParameters.ContainsKey('DeviceCode')) { + $body += @{ + 'device_code' = $DeviceCode + 'grant_type' = 'urn:ietf:params:oauth:grant-type:device_code' + } + } + + $RESTParams = @{ + Uri = 'https://github.com/login/oauth/access_token' + Method = 'POST' + Body = $body + Headers = @{ 'Accept' = 'application/json' } + } + + try { + Write-Verbose ($RESTParams.GetEnumerator() | Out-String) + + $tokenResponse = Invoke-RestMethod @RESTParams -Verbose:$false + return $tokenResponse + } catch { + Write-Error $_ + throw $_ + } +} diff --git a/src/GitHub/private/Auth/DeviceFlow/Request-GitHubDeviceCode.ps1 b/src/GitHub/private/Auth/DeviceFlow/Request-GitHubDeviceCode.ps1 new file mode 100644 index 000000000..bdc393b51 --- /dev/null +++ b/src/GitHub/private/Auth/DeviceFlow/Request-GitHubDeviceCode.ps1 @@ -0,0 +1,59 @@ +function Request-GitHubDeviceCode { + <# + .SYNOPSIS + Request a GitHub Device Code. + + .DESCRIPTION + Request a GitHub Device Code. + + .EXAMPLE + Request-GitHubDeviceCode -ClientID $ClientID -Mode $Mode + + This will request a GitHub Device Code. + + .NOTES + For more info about the Device Flow visit: + https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-cli-with-a-github-app + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + # The Client ID of the GitHub App. + [Parameter(Mandatory)] + [string] $ClientID, + + # The scope of the access token, when using OAuth authentication. + # Provide the list of scopes as space-separated values. + # For more information on scopes visit: + # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + [Parameter()] + [string] $Scope = 'gist, read:org, repo, workflow' + ) + + $headers = @{ + Accept = 'application/json' + } + + $body = @{ + client_id = $ClientID + scope = $Scope + } + + $RESTParams = @{ + Uri = 'https://github.com/login/device/code' + Method = 'POST' + Body = $body + Headers = $headers + } + + try { + Write-Verbose ($RESTParams.GetEnumerator() | Out-String) + + $deviceCodeResponse = Invoke-RestMethod @RESTParams -Verbose:$false + return $deviceCodeResponse + } catch { + Write-Error $_ + throw $_ + } +} + diff --git a/src/GitHub/private/Auth/DeviceFlow/Wait-GitHubAccessToken.ps1 b/src/GitHub/private/Auth/DeviceFlow/Wait-GitHubAccessToken.ps1 new file mode 100644 index 000000000..cc1e0411d --- /dev/null +++ b/src/GitHub/private/Auth/DeviceFlow/Wait-GitHubAccessToken.ps1 @@ -0,0 +1,113 @@ +function Wait-GitHubAccessToken { + <# + .SYNOPSIS + Waits for the GitHub Device Flow to complete. + + .DESCRIPTION + Waits for the GitHub Device Flow to complete. + This will poll the GitHub API until the user has entered the code. + + .EXAMPLE + Wait-GitHubAccessToken -DeviceCode $deviceCode -ClientID $ClientID -Interval $interval + + This will poll the GitHub API until the user has entered the code. + + .EXAMPLE + Wait-GitHubAccessToken -Refresh -ClientID $ClientID + + .NOTES + For more info about the Device Flow visit: + https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/building-a-cli-with-a-github-app + #> + [OutputType([PSCustomObject])] + [CmdletBinding(DefaultParameterSetName = 'DeviceFlow')] + param( + # The Client ID of the GitHub App. + [Parameter(Mandatory)] + [string] $ClientID, + + # The device code used to request the access token. + [Parameter( + Mandatory, + ParameterSetName = 'DeviceFlow' + )] + [string] $DeviceCode, + + # The refresh token used to request a new access token. + [Parameter( + Mandatory, + ParameterSetName = 'RefreshToken' + )] + [string] $RefreshToken, + + # The interval to wait between polling for the token. + [Parameter()] + [int] $Interval = 5 + + ) + + do { + if ($RefreshToken) { + $response = Request-GitHubAccessToken -ClientID $ClientID -RefreshToken $RefreshToken + } else { + $response = Request-GitHubAccessToken -ClientID $ClientID -DeviceCode $DeviceCode + } + if ($response.error) { + switch ($response.error) { + 'authorization_pending' { + # The user has not yet entered the code. + # Wait, then poll again. + Write-Verbose $response.error_description + Start-Sleep -Seconds $interval + continue + } + 'slow_down' { + # The app polled too fast. + # Wait for the interval plus 5 seconds, then poll again. + Write-Verbose $response.error_description + Start-Sleep -Seconds ($interval + 5) + continue + } + 'expired_token' { + # The 'device_code' expired, and the process needs to restart. + Write-Error $response.error_description + exit 1 + } + 'unsupported_grant_type' { + # The 'grant_type' is not supported. + Write-Error $response.error_description + exit 1 + } + 'incorrect_client_credentials' { + # The 'client_id' is not valid. + Write-Error $response.error_description + exit 1 + } + 'incorrect_device_code' { + # The 'device_code' is not valid. + Write-Error $response.error_description + exit 2 + } + 'access_denied' { + # The user cancelled the process. Stop polling. + Write-Error $response.error_description + exit 1 + } + 'device_flow_disabled' { + # The GitHub App does not support the Device Flow. + Write-Error $response.error_description + exit 1 + } + default { + # The response contains an access token. Stop polling. + Write-Error 'Unknown error:' + Write-Error $response.error + Write-Error $response.error_description + Write-Error $response.error_uri + break + } + } + } + } until ($response.access_token) + $response +} diff --git a/src/GitHub/private/Config/Config.ps1 b/src/GitHub/private/Config/Config.ps1 new file mode 100644 index 000000000..de2131326 --- /dev/null +++ b/src/GitHub/private/Config/Config.ps1 @@ -0,0 +1,28 @@ +$script:ConfigTemplate = [pscustomobject]@{ # $script:ConfigTemplate + App = [pscustomobject]@{ # $script:ConfigTemplate.App + API = [pscustomobject]@{ # $script:ConfigTemplate.App.API + BaseURI = 'https://api.github.com' # $script:ConfigTemplate.App.API.BaseURI + Version = '2022-11-28' # $script:ConfigTemplate.App.API.Version + } + Defaults = [pscustomobject]@{} # $script:ConfigTemplate.App.Defaults + } + User = [pscustomobject]@{ # $script:ConfigTemplate.User + Auth = [pscustomobject]@{ # $script:ConfigTemplate.User.Auth + AccessToken = [pscustomobject]@{ # $script:ConfigTemplate.User.Auth.AccessToken + Value = '' # $script:ConfigTemplate.User.Auth.AccessToken.Value + ExpirationDate = [datetime]::MinValue # $script:ConfigTemplate.User.Auth.AccessToken.ExpirationDate + } + ClientID = '' # $script:ConfigTemplate.User.Auth.ClientID + Mode = '' # $script:ConfigTemplate.User.Auth.Mode + RefreshToken = [pscustomobject]@{ + Value = '' # $script:ConfigTemplate.User.Auth.RefreshToken.Value + ExpirationDate = [datetime]::MinValue # $script:ConfigTemplate.User.Auth.RefreshToken.ExpirationDate + } + Scope = '' # $script:ConfigTemplate.User.Auth.Scope + } + Defaults = [pscustomobject]@{ # $script:ConfigTemplate.User.Defaults + Owner = '' # $script:ConfigTemplate.User.Defaults.Owner + Repo = '' # $script:ConfigTemplate.User.Defaults.Repo + } + } +} diff --git a/src/GitHub/private/Config/Initialize-SecretVault.ps1 b/src/GitHub/private/Config/Initialize-SecretVault.ps1 index 9dc04b7cd..05b619d5c 100644 --- a/src/GitHub/private/Config/Initialize-SecretVault.ps1 +++ b/src/GitHub/private/Config/Initialize-SecretVault.ps1 @@ -33,9 +33,9 @@ function Initialize-SecretVault { $secretVault = Get-SecretVault | Where-Object { $_.ModuleName -eq $Type } $secretVaultExists = $secretVault.count -ne 0 - Write-Verbose "A $Name exists: $secretVaultExists" + Write-Verbose "[$Name] - exists - [$secretVaultExists]" if (-not $secretVaultExists) { - Write-Verbose "Registering [$Name]" + Write-Verbose "[$Name] - Registering" switch ($Type) { 'Microsoft.PowerShell.SecretStore' { diff --git a/src/GitHub/public/Auth/Connect-GitHubAccount.ps1 b/src/GitHub/public/Auth/Connect-GitHubAccount.ps1 new file mode 100644 index 000000000..af7999361 --- /dev/null +++ b/src/GitHub/public/Auth/Connect-GitHubAccount.ps1 @@ -0,0 +1,128 @@ +function Connect-GitHubAccount { + <# + .SYNOPSIS + Connects to GitHub using a personal access token or device code login. + + .DESCRIPTION + Connects to GitHub using a personal access token or device code login. + + For device flow / device code login: + PowerShell requests device and user verification codes and gets the authorization URL where you will enter the user verification code. + In GitHub you will be asked to enter a user verification code at https://github.com/login/device. + PowerShell will keep polling GitHub for the user authentication status. Once you have authorized the device, + the app will be able to make API calls with a new access token. + + .EXAMPLE + Connect-GitHubAccount + + Connects to GitHub using a device flow login. + + .EXAMPLE + Connect-GitHubAccount -AccessToken 'ghp_####' + + Connects to GitHub using a personal access token (PAT). + + .EXAMPLE + Connect-GitHubAccount -Refresh + + Refreshes the access token. + + .EXAMPLE + Connect-GitHubAccount -Mode 'OAuthApp' -Scope 'gist read:org repo workflow' + + Connects to GitHub using a device flow login and sets the scope of the access token. + + .NOTES + https://docs.github.com/en/rest/overview/other-authentication-methods#authenticating-for-saml-sso + #> + [Alias('Connect-GHAccount')] + [Alias('Connect-GitHub')] + [Alias('Connect-GH')] + [Alias('Login-GitHubAccount')] + [Alias('Login-GHAccount')] + [Alias('Login-GitHub')] + [Alias('Login-GH')] + [OutputType([void])] + [CmdletBinding(DefaultParameterSetName = 'DeviceFlow')] + param ( + # Choose between authentication methods, either OAuthApp or GitHubApp. + # For more info about the types of authentication visit: + # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/differences-between-github-apps-and-oauth-apps + [Parameter(ParameterSetName = 'DeviceFlow')] + [ValidateSet('OAuthApp', 'GitHubApp')] + [string] $Mode = 'GitHubApp', + + # The scope of the access token, when using OAuth authentication. + # Provide the list of scopes as space-separated values. + # For more information on scopes visit: + # https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps + [Parameter(ParameterSetName = 'DeviceFlow')] + [string] $Scope, + + # Refresh the access token. + [Parameter( + Mandatory, + ParameterSetName = 'Refresh' + )] + [switch] $Refresh, + + # The personal access token to use for authentication. + [Parameter( + Mandatory, + ParameterSetName = 'PAT' + )] + [String] $AccessToken + ) + + $vault = Get-SecretVault | Where-Object -Property ModuleName -EQ $script:SecretVault.Type + + if ($null -eq $vault) { + Initialize-SecretVault -Name $script:SecretVault.Name -Type $script:SecretVault.Type + $vault = Get-SecretVault | Where-Object -Property ModuleName -EQ $script:SecretVault.Type + } + + $clientID = $script:Auth.$Mode.ClientID + + switch ($PSCmdlet.ParameterSetName) { + 'Refresh' { + Write-Verbose 'Refreshing access token...' + $tokenResponse = Invoke-GitHubDeviceFlowLogin -ClientID $clientID -RefreshToken $script:Config.User.Auth.RefreshToken.Value + } + 'DeviceFlow' { + Write-Verbose 'Logging in using device flow...' + if ([string]::IsNullOrEmpty($Scope) -and ($Mode -eq 'OAuthApp')) { + $Scope = 'gist read:org repo workflow' + } + if ($script:Config.PSObject.Properties.Name -contains 'App') { + Reset-GitHubConfig -Scope 'User.Auth' + } else { + Reset-GitHubConfig -Scope 'All' + } + $tokenResponse = Invoke-GitHubDeviceFlowLogin -ClientID $clientID -Scope $Scope + $script:Config.User.Auth.Mode = $Mode + $script:Config.User.Auth.ClientID = $clientID + } + 'PAT' { + Write-Verbose 'Logging in using personal access token...' + Reset-GitHubConfig -Scope 'User.Auth' + $script:Config.User.Auth.AccessToken.Value = $Token + $script:Config.User.Auth.Mode = 'PAT' + Save-GitHubConfig + Write-Host '✓ ' -ForegroundColor Green -NoNewline + Write-Host 'Logged in using a personal access token (PAT)!' + return + } + } + + if ($tokenResponse) { + $script:Config.User.Auth.AccessToken.Value = $tokenResponse.access_token + $script:Config.User.Auth.AccessToken.ExpirationDate = (Get-Date).AddSeconds($tokenResponse.expires_in) + $script:Config.User.Auth.RefreshToken.Value = $tokenResponse.refresh_token + $script:Config.User.Auth.RefreshToken.ExpirationDate = (Get-Date).AddSeconds($tokenResponse.refresh_token_expires_in) + $script:Config.User.Auth.Scope = $tokenResponse.scope + } + + Save-GitHubConfig + Write-Host '✓ ' -ForegroundColor Green -NoNewline + Write-Host 'Logged in to GitHub!' +} diff --git a/src/GitHub/public/Auth/Disconnect-GitHubAccount.ps1 b/src/GitHub/public/Auth/Disconnect-GitHubAccount.ps1 new file mode 100644 index 000000000..6df6137a1 --- /dev/null +++ b/src/GitHub/public/Auth/Disconnect-GitHubAccount.ps1 @@ -0,0 +1,33 @@ +function Disconnect-GitHubAccount { + <# + .SYNOPSIS + Disconnects from GitHub and removes the current GitHub configuration. + + .DESCRIPTION + Disconnects from GitHub and removes the current GitHub configuration. + + .EXAMPLE + Disconnect-GitHubAccount + + Disconnects from GitHub and removes the current GitHub configuration. + #> + [Alias('Disconnect-GHAccount')] + [Alias('Disconnect-GitHub')] + [Alias('Disconnect-GH')] + [Alias('Logout-GitHubAccount')] + [Alias('Logout-GHAccount')] + [Alias('Logout-GitHub')] + [Alias('Logout-GH')] + [Alias('Logoff-GitHubAccount')] + [Alias('Logoff-GHAccount')] + [Alias('Logoff-GitHub')] + [Alias('Logoff-GH')] + [OutputType([void])] + [CmdletBinding()] + param () + + Reset-GitHubConfig + + Write-Host "✓ " -ForegroundColor Green -NoNewline + Write-Host "Logged out of GitHub!" +} diff --git a/src/GitHub/public/Config/Reset-GitHubConfig.ps1 b/src/GitHub/public/Config/Reset-GitHubConfig.ps1 index beb9bd323..42e5994ad 100644 --- a/src/GitHub/public/Config/Reset-GitHubConfig.ps1 +++ b/src/GitHub/public/Config/Reset-GitHubConfig.ps1 @@ -20,24 +20,36 @@ [OutputType([void])] [CmdletBinding()] param( + # Reset the GitHub configuration for a specific scope. [Parameter()] - [ValidateSet('App', 'App.API', 'App.Defaults', 'All')] + [ValidateSet('App', 'App.API', 'App.Defaults', 'User', 'User.Auth', 'User.Defaults', 'All')] [string] $Scope = 'All' ) - switch($Scope) { + Write-Verbose "Resetting GitHub configuration for scope '$Scope'..." + switch ($Scope) { 'App' { - $script:Config.App = $script:ConfigTemplate.App + $script:Config.App = $script:ConfigTemplate.App | ConvertTo-Json -Depth 100 | ConvertFrom-Json } 'App.API' { - $script:Config.App.API = $script:ConfigTemplate.App.API + $script:Config.App.API = $script:ConfigTemplate.App.API | ConvertTo-Json -Depth 100 | ConvertFrom-Json } 'App.Defaults' { - $script:Config.App.Defaults = $script:ConfigTemplate.App.Defaults + $script:Config.App.Defaults = $script:ConfigTemplate.App.Defaults | ConvertTo-Json -Depth 100 | ConvertFrom-Json + } + 'User' { + $script:Config.User = $script:ConfigTemplate.User | ConvertTo-Json -Depth 100 | ConvertFrom-Json + } + 'User.Auth' { + $script:Config.User.Auth = $script:ConfigTemplate.User.Auth | ConvertTo-Json -Depth 100 | ConvertFrom-Json + } + 'User.Defaults' { + $script:Config.User.Defaults = $script:ConfigTemplate.User.Defaults | ConvertTo-Json -Depth 100 | ConvertFrom-Json } 'All' { - $script:Config = $script:ConfigTemplateDefaults + $script:Config = $script:ConfigTemplate | ConvertTo-Json -Depth 100 | ConvertFrom-Json } } + Save-GitHubConfig } diff --git a/src/GitHub/public/Config/Restore-GitHubConfig.ps1 b/src/GitHub/public/Config/Restore-GitHubConfig.ps1 index c4cc3188f..48ed8d390 100644 --- a/src/GitHub/public/Config/Restore-GitHubConfig.ps1 +++ b/src/GitHub/public/Config/Restore-GitHubConfig.ps1 @@ -24,15 +24,15 @@ function Restore-GitHubConfig { $vault = Get-SecretVault -Name $script:SecretVault.Name $vaultExists = $vault.count -eq 1 if ($vaultExists) { - $secretExists = Get-SecretInfo -Name $script:Secret.Name -Vault $script:SecretVault.Name + $secretExists = Get-SecretInfo -Name $script:SecretVault.Secret.Name -Vault $script:SecretVault.Name if ($secretExists) { - $script:Config = Get-Secret -Name $script:Secret.Name -AsPlainText -Vault $script:SecretVault.Name | ConvertFrom-Json + $script:Config = Get-Secret -Name $script:SecretVault.Secret.Name -AsPlainText -Vault $script:SecretVault.Name | ConvertFrom-Json } else { - Write-Warning "Unable to restore configuration." - Write-Warning "The secret [$($script:Secret.Name)] does not exist in the vault [$($script:SecretVault.Name)]." + Write-Verbose "Unable to restore configuration." + Write-Verbose "The secret [$($script:SecretVault.Secret.Name)] does not exist in the vault [$($script:SecretVault.Name)]." } } else { - Write-Warning "Unable to restore configuration." - Write-Warning "The vault [$($script:SecretVault.Name)] does not exist." + Write-Verbose "Unable to restore configuration." + Write-Verbose "The vault [$($script:SecretVault.Name)] does not exist." } } diff --git a/src/GitHub/public/Config/Save-GitHubConfig.ps1 b/src/GitHub/public/Config/Save-GitHubConfig.ps1 index 0288e3a03..76a861eb7 100644 --- a/src/GitHub/public/Config/Save-GitHubConfig.ps1 +++ b/src/GitHub/public/Config/Save-GitHubConfig.ps1 @@ -19,6 +19,6 @@ function Save-GitHubConfig { [CmdletBinding()] param() - $config = $script:Config | ConvertTo-Json -Depth 100 - Set-Secret -Name $script:Secret.Name -Secret $config -Vault $script:SecretVault.Name + $configJson = $script:Config | ConvertTo-Json -Depth 100 + Set-Secret -Name $script:SecretVault.Secret.Name -Secret $configJson -Vault $script:SecretVault.Name } diff --git a/src/GitHub/public/Config/Set-GitHubConfig.ps1 b/src/GitHub/public/Config/Set-GitHubConfig.ps1 index e802cdc35..8b85baf7a 100644 --- a/src/GitHub/public/Config/Set-GitHubConfig.ps1 +++ b/src/GitHub/public/Config/Set-GitHubConfig.ps1 @@ -20,16 +20,29 @@ # Set the GitHub API Version. [Parameter()] - [string] $APIVersion + [string] $APIVersion, + + # Set the default for the Owner parameter. + [Parameter()] + [string] $Owner, + + # Set the default for the Repo parameter. + [Parameter()] + [string] $Repo ) switch ($PSBoundParameters.Keys) { 'APIBaseURI' { - $script:ConfigTemplate.App.API.BaseURI = $APIBaseURI + $script:Config.App.API.BaseURI = $APIBaseURI } - 'APIVersion' { - $script:ConfigTemplate.App.API.Version = $APIVersion + $script:Config.App.API.Version = $APIVersion + } + 'Owner' { + $script:Config.User.Defaults.Owner = $Owner + } + 'Repo' { + $script:Config.User.Defaults.Repo = $Repo } } Save-GitHubConfig diff --git a/src/GitHub/public/loader.ps1 b/src/GitHub/public/loader.ps1 deleted file mode 100644 index 100a83fbe..000000000 --- a/src/GitHub/public/loader.ps1 +++ /dev/null @@ -1,2 +0,0 @@ -Initialize-SecretVault -Name $script:SecretVault.Name -Type $script:SecretVault.Type -Restore-GitHubConfig diff --git a/tools/utilities/Local-Testing.ps1 b/tools/utilities/Local-Testing.ps1 index 84fdbd8ef..e3b0bb031 100644 --- a/tools/utilities/Local-Testing.ps1 +++ b/tools/utilities/Local-Testing.ps1 @@ -3,8 +3,24 @@ Get-Module -Name GitHub -ListAvailable | Remove-Module -Force Get-Module -Name GitHub -ListAvailable | Uninstall-Module -Force -AllVersions Get-SecretVault | Unregister-SecretVault - +Get-SecretVault Get-Module -Name GitHub -ListAvailable Install-Module -Name GitHub -Verbose -Force -AllowPrerelease + +$VerbosePreference = 'Continue' +Import-Module -Name 'C:\Repos\GitHub\PSModule\Modules\GitHub\src\GitHub\GitHub.psm1' -Verbose -Force + +Import-Module -Name GitHub -Verbose Clear-Host -Get-GitHubConfig +Get-Command -Module GitHub +Get-Variable | Where-Object -Property Module -ne $null | Select-Object Name, Module, ModuleName +Connect-GitHubAccount +Get-GitHubConfig | ConvertTo-Json -Depth 100 +Get-GitHubConfig -Refresh | ConvertTo-Json -Depth 100 +Restore-GitHubConfig -Verbose +Get-GitHubContext + +Connect-GitHubAccount -Refresh -Verbose + +Disconnect-GitHubAccount -Verbose +Reset-GitHubConfig -Verbose