diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a0f8b25 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: "CodeQL" + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: '41 2 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: 'ubuntu-latest' + timeout-minutes: 15 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:go" diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index d5baafd..61e5669 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -1,52 +1,23 @@ ---- -################################# -################################# -## Super Linter GitHub Actions ## -################################# -################################# -name: Lint Code Base +name: Lint -# -# Documentation: -# https://help.github.com/en/articles/workflow-syntax-for-github-actions -# - -############################# -# Start the job on all push # -############################# on: - push: - branches-ignore: [master, main] + pull_request: + +concurrency: + group: ${{ github.ref }}-${{ github.workflow }} + cancel-in-progress: true + +permissions: + contents: read -############### -# Set the Job # -############### jobs: - build: - # Name the Job - name: Lint Code Base - # Set the agent to run on + lint: + name: Lint runs-on: ubuntu-latest - - ################## - # Load all steps # - ################## steps: - ########################## - # Checkout the code base # - ########################## - name: Checkout Code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: - # Full git history is needed to get a proper list of changed files within `super-linter` fetch-depth: 0 - - ################################ - # Run Linter against code base # - ################################ - - name: Lint Code Base - uses: github/super-linter@v4 - env: - VALIDATE_ALL_CODEBASE: true - DEFAULT_BRANCH: main - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Lint Code + uses: golangci/golangci-lint-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c81c971..5b48330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,34 +1,14 @@ name: Release - on: - workflow_dispatch: push: - branches: - - main - paths: - - 'gh-token' + tags: + - "v*" +permissions: + contents: write jobs: - Update: - # The type of runner that the job will run on + release: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - name: "Checkout repo" - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: "Calculate new SHA256 hash" - run: | - new_sha="$(shasum -a 256 gh-token | sed -r 's/gh-token/ghtoken/g')" - sed -r "s/echo \"[0-9a-f]{64} ghtoken\"/echo \"$new_sha\"/g" -i README.md - - - name: "Commit and push updates" - uses: EndBug/add-and-commit@a3adef035a1381dcf888c90b847240e2ddb9e008 - with: - author_name: Link- - author_email: '568794+Link-@users.noreply.github.com' - message: 'Updating sha256 hash value' - add: 'README.md' + - uses: actions/checkout@v3 + - uses: cli/gh-extension-precompile@v1 diff --git a/.gitignore b/.gitignore index a5d9fb7..ba594a9 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,11 @@ tags .keys ### Project files -jwt \ No newline at end of file +jwt + +# Generated files +gh-token +gh-token.exe + +# Test app keys +*.pem diff --git a/LICENSE b/LICENSE index 1fa6ed7..7645133 100644 --- a/LICENSE +++ b/LICENSE @@ -11,3 +11,4 @@ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + diff --git a/README.md b/README.md index b8c439e..a2d8688 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GH Token -```sh +```shell * _____ _ *_ _______ * _ * * ** * / ____| |* | | |__ __| | | * * 🦄 * | | *__| |_*| | ⭐️ | | ___ | | _____*_ __ * * @@ -13,7 +13,7 @@ > Create an installation access token for a GitHub app from your terminal -[![ghtoken size](https://img.shields.io/github/size/link-/gh-token/gh-token?style=flat-square)](ghtoken) [![License](https://img.shields.io/github/license/link-/gh-token?style=flat-square)](LICENSE) ![platforms supported](https://img.shields.io/static/v1?style=flat-square&label=platform&message=macos%20%7C%20linux) +[![gh-token size](https://img.shields.io/github/size/link-/gh-token/gh-token?style=flat-square)](gh-token) [![License](https://img.shields.io/github/license/link-/gh-token?style=flat-square)](LICENSE) ![platforms supported](https://img.shields.io/static/v1?style=flat-square&label=platform&message=macos%20%7C%20linux) [Creates an installation access token](https://docs.github.com/en/rest/reference/apps#create-an-installation-access-token-for-an-app) that enables a GitHub App to make authenticated API requests for the app's installation on an organization or individual account. @@ -21,14 +21,7 @@ Installation tokens expire 1 hour from the time you create them. Using an expire You can use this access token to make pretty much any REST or GraphQL API call the app is authorized to make! -![ghtoken demo](./images/ghtoken.png) - -
- Expand for Demo - - ![Demo](./images/demo.gif) - -
+![gh-token demo](./images/gh-token.png) ## Why? @@ -44,51 +37,22 @@ With an access token generated with a GitHub App you don't have to worry about t ## Installation -### Prerequisites - -- `Bash 5.x+` -- `jq` -- `shasum` - -Download `ghtoken` [from the main branch](https://github.com/Link-/gh-token/blob/main/gh-token) - -### wget +### Download as a standalone binary -```sh -# Download a file, name it ghtoken then do a checksum -wget -O ghtoken \ - https://raw.githubusercontent.com/Link-/gh-token/main/gh-token && \ - echo "6a6b111355432e08dd60ac0da148e489cdb0323a059ee8cbe624fd37bf2572ae ghtoken" | \ - shasum -c - && \ - chmod u+x ./ghtoken -``` - -### curl - -```sh -# Download a file, name it ghtoken following [L]ocation redirects, and -# automatically [C]ontinuing (resuming) a previous file transfer then -# do a checksum -curl -o ghtoken \ - -O -L -C - \ - https://raw.githubusercontent.com/Link-/gh-token/main/gh-token && \ - echo "6a6b111355432e08dd60ac0da148e489cdb0323a059ee8cbe624fd37bf2572ae ghtoken" | \ - shasum -c - && \ - chmod u+x ./ghtoken -``` +Download `gh-token` from the [latest release](https://github.com/Link-/gh-token/releases/latest) for your platform. -### gh cli extension +### Install as a `gh` cli extension -You can install `ghtoken` as a [gh cli](https://github.com/cli/cli) extension! +You can install `gh-token` as a [gh cli](https://github.com/cli/cli) extension! -```sh -gh extensions install Link-/gh-token +```shell +$ gh extension install Link-/gh-token # Verify installation -gh token +$ gh token ``` -All the commands and parameters remain the same, the only different is you now can use `gh token` instead of `ghtoken`. +All the commands and parameters remain the same, the only different is you now can use `gh token` instead of `gh-token`. ### Creating a GitHub App @@ -99,155 +63,98 @@ Follow [these steps](https://docs.github.com/en/developers/apps/creating-a-githu Compatible with [GitHub Enterprise Server](https://github.com/enterprise). ```text +NAME: + gh-token - Generate a GitHub App installation token -Usage: - ghtoken generate (--key | --base64_key ) --app_id [--duration ] [--installation_id ] [--hostname ] [--install_jwt_cli] - ghtoken installations (--key | -base64_key ) --app_id [--duration ] [--hostname ] [--install_jwt_cli] - ghtoken revoke --token [--hostname ] - ghtoken -h | --help - ghtoken --version - -Options: - -h --help Display this help information. - --version Display version information. - -k , --key Path to a PEM-encoded certificate and key. [required] - -b , --base64_key Base64 encoded PEM certificate and key. [optional] - -i , --app_id GitHub App Id. [required] - -d , --duration The expiration duration of the JWT in minutes. [default: 10] - -o , --hostname The API URL of GitHub. [default: api.github.com] - -j, --install_jwt_cli Install jwt-cli (dependency) on the current system. [optional] - -l , --installation_id GitHub App installation id. [default: latest id] - -t , --token Access token to revoke. [required] -``` - -### Examples in the Terminal - -#### Run `ghtoken` assuming `jwt-cli` is already installed +USAGE: + gh-token [global options] command [command options] [arguments...] -```sh -# Assumed starting point -. -├── .keys -│   └── private-key.pem -├── README.md -└── ghtoken +VERSION: + 2.0.0 -1 directory, 3 files +COMMANDS: + generate Generate a new GitHub App installation token + revoke Revoke a GitHub App installation token + installations List GitHub App installations + help, h Shows a list of commands or help for one command -# Run ghtoken -$ ghtoken generate \ - --key ./.keys/private-key.pem \ - --app_id 1122334 \ - | jq - -{ - "token": "ghs_g7___MlQiHCYI__________7j1IY2thKXF", - "expires_at": "2021-04-28T15:53:44Z" -} +GLOBAL OPTIONS: + --help, -h show help + --version, -v print the version ``` -#### Run `ghtoken` and install `jwt-cli` - -```sh -# Assumed starting point -. -├── .keys -│   └── private-key.pem -├── README.md -└── ghtoken +### Examples in the Terminal -1 directory, 3 files +#### Run `gh token` as a `gh` CLI extension -# Run ghtoken and add --install_jwt_cli -$ ghtoken generate \ +```shell +$ gh token generate \ --key ./.keys/private-key.pem \ - --app_id 1122334 \ - --install_jwt_cli \ - | jq + --app-id 1122334 \ + --installation-id 5566778 { "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", - "expires_at": "2021-04-28T15:55:32Z" + "expires_at": "2023-09-08T18:11:34Z", + "permissions": { + "actions": "write", + "administration": "write", + "metadata": "read", + "members": "read", + "organization_administration": "read" + } } - -# jwt-cli will be downloaded in the same directory -. -├── .keys -│   └── private-repo-checkout.2021-04-22.private-key.pem -├── README.md -├── ghtoken -└── jwt ``` -#### Run `ghtoken` and pass the key as a base64 encoded variable - -```sh -# Assumed starting point -. -├── README.md -└── ghtoken +#### Run `gh token` and pass the key as a base64 encoded string -1 directory, 2 files - -# Run ghtoken and add --install_jwt_cli -$ ghtoken generate \ - --base64_key $(printf "%s" $APP_KEY | base64) \ - --app_id 1122334 \ - --install_jwt_cli \ - | jq +```shell +$ gh token generate \ + --key-base64 $(printf "%s" $APP_KEY | base64) \ + --app-id 1122334 \ + --installation-id 5566778 { - "token": "ghs_GxVel5cp__________DOaCv8eDs___2l94Ta", - "expires_at": "2021-04-28T16:30:59Z" + "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", + "expires_at": "2023-09-08T18:11:34Z", + "permissions": { + "actions": "write", + "administration": "write", + "metadata": "read", + "members": "read", + "organization_administration": "read" + } } ``` -#### Run `ghtoken` with GitHub Enterprise Server - -```sh -# Assumed starting point -. -├── .keys -│   └── private-key.pem -├── README.md -└── ghtoken - -1 directory, 3 files +#### Run `gh token` with GitHub Enterprise Server -# Run ghtoken and specify the --hostname -$ ghtoken generate \ - --key ./.keys/private-key.pem \ - --app_id 2233445 \ - --installation_id 5 \ - --install_jwt_cli \ - --hostname "github.example.com" \ - | jq +```shell +$ gh token generate \ + --key-base64 $(printf "%s" $APP_KEY | base64) \ + --app-id 1122334 \ + --installation-id 5566778 \ + --hostname "github.example.com" { - "token": "v1.bb1___168d_____________1202bb8753b133919", - "expires_at": "2021-04-28T16:01:05Z" + "token": "ghs_8Joht_______________bLCMS___M0EPOhJ", + "expires_at": "2023-09-08T18:11:34Z", + "permissions": { + "actions": "write", + "administration": "write", + "metadata": "read", + "members": "read", + "organization_administration": "read" + } } ``` #### Fetch list of installations for an app -```sh -# Assumed starting point -. -├── .keys -│   └── private-key.pem -├── README.md -└── ghtoken - -1 directory, 3 files - -# Run ghtoken and specify the --hostname -$ ghtoken installations \ - --key ./.keys/private-key.pem \ - --app_id 2233445 \ - --install_jwt_cli \ - --hostname "github.example.com" \ - | jq +```shell +$ gh token installations \ + --key ./private-key.pem \ + --app-id 2233445 ```
@@ -312,43 +219,14 @@ $ ghtoken installations \ #### Revoke an installation access token -```sh -# Run ghtoken with the revoke command -$ ghtoken revoke \ - --token "v1.bb1___168d_____________1202bb8753b133919" +```shell +$ gh token revoke \ + --token "v1.bb1___168d_____________1202bb8753b133919" \ --hostname "github.example.com" -204: Token revoked successfully +Successfully revoked installation token ``` -#### Integrate with [GitHub's CLI](https://github.com/cli/cli) - -You can use ghtoken alongside [gh cli](https://github.com/cli/cli) by setting these aliases: - -```sh -# Add ghtoken to a folder included in your PATH environment variable -ln -s /ghtoken /usr/local/bin/ghtoken - -# Set a token generation alias. This will always generate a token from -# the app ID and key supplied. -$ gh alias set token -s 'ghtoken generate --key /tmp/private-key.pem --app_id 112233' - -# Usage -$ gh token - -{ - "token": "ghs_1gCKrYvkh3_______7JZFlZw______w1FE", - "expires_at": "2021-05-15T22:34:10Z" -} - -# You can also set an alias to revoke tokens -$ gh alias set revokeToken -s 'ghtoken revoke --token "$1"' - -# Usage -$ gh revokeToken "ghs_1gCKrYvkh3_______7JZFlZw______w1FE" - -204: Token revoked successfully -``` ### Example in a workflow @@ -377,27 +255,19 @@ jobs: runs-on: [ self-hosted ] steps: - - name: "Download ghtoken" - run: | - curl -o ghtoken \ - -O -L -C - \ - https://raw.githubusercontent.com/Link-/gh-token/main/gh-token && \ - echo "6a6b111355432e08dd60ac0da148e489cdb0323a059ee8cbe624fd37bf2572ae ghtoken" | \ - shasum -c - && \ - chmod u+x ./ghtoken + - name: "Install gh-token" + run: gh extension install Link-/gh-token # Create access token with a GitHub App ID and Key # We use the private key stored as a secret and encode it into base64 - # before passing it to ghtoken + # before passing it to gh-token - name: "Create access token" - id: "create_token" run: | - token=$(./ghtoken generate \ - --base64_key $(printf "%s" "$APP_PRIVATE_KEY" | base64 -w 0) \ - --app_id $APP_ID \ - --install_jwt_cli \ + token=$(./gh token generate \ + --key-base64 $(printf "%s" "$APP_PRIVATE_KEY" | base64 -w 0) \ + --app-id $APP_ID \ --hostname "github.example.com" \ | jq -r ".token") - echo "::set-output name=token::$token" + echo "token=$token" >> $GITHUB_OUTPUT env: APP_ID: ${{ secrets.APP_ID }} APP_PRIVATE_KEY: ${{ secrets.APP_KEY }} @@ -406,41 +276,13 @@ jobs: - name: "Fetch organization repositories" run: | curl -X GET \ - -H "Authorization: token ${{ steps.create_token.outputs.token }}" \ + -H "Authorization: token $token" \ -H "Accept: application/vnd.github.v3+json" \ https://github.example.com/api/v3/orgs//repos ```
-## Troubleshoot - -### I'm getting: `Something went awry creating the jwt` with `ghtoken generate` - -Make sure your `pem` file has the extension `.pem`. This is necessary for `jwt-cli` to be able to determine the type of key it's trying to parse. - -#### I get `null` values for `token` and `expiration date` - -If you see this response: - -```sh -{ - "token": null, - "expires_at": null -} -``` - -This is an indication that the script was not able to fetch an `installation id` and that the GitHub App has not been `installed` for an Organization or User. - -#### I get a weird syntax error - -Make sure you're running `bash 5.x+`. If you're running MacOS, the version of `bash` installed is `3.2` which is not compatible with this tool. - -```sh -# Upgrade bash and that should resolve your problem -brew upgrade bash -``` - ## Similar projects _These are not endorsements, just a listing of similar art work_ diff --git a/gh-token b/gh-token deleted file mode 100755 index b98e893..0000000 --- a/gh-token +++ /dev/null @@ -1,1165 +0,0 @@ -#!/usr/bin/env bash - -if (( ${BASH_VERSION%%.*} < 5 )); then - echo "Bash is too old: ${BASH_VERSION}; Bash 5.+ is required" - exit 1 -fi - -############################################################################### -# * _____ _ *_ _______ * _ * * ** * -# / ____| |* | | |__ __| | | * * 🦄 * -# | | *__| |_*| | ⭐ | | ___ | | _____*_ __ * * -# | | |_ |* __ *| |*|/ _ \| |/ / _ \ '_ \ * * -# | |__| | | | | * | | (_)*| < __/ | | | * -# \_____|_| |_| |_|\___/|_|\_\___|_| |_| * -# -# -# Depends on: -# - jwt-cli (https://github.com/mike-engel/jwt-cli) -# -# Based on: -# - Bash Boilerplate: https://github.com/xwmx/bash-boilerplate -# Copyright (c) 2015 William Melody • hi@williammelody.com -############################################################################### - -# Short form: set -u -set -o nounset - -# Exit immediately if a pipeline returns non-zero. -# Short form: set -e -set -o errexit - -# Print a helpful message if a pipeline with non-zero exit code causes the -# script to exit as described above. -trap 'echo "Aborting due to errexit on line $LINENO. Exit code: $?" >&2' ERR - -# Allow the above trap be inherited by all functions in the script. -# -# Short form: set -E -set -o errtrace - -# Return value of a pipeline is the value of the last (rightmost) command to -# exit with a non-zero status, or zero if all commands in the pipeline exit -# successfully. -set -o pipefail - -# Set $IFS to only newline and tab. -# -# http://www.dwheeler.com/essays/filenames-in-shell.html -IFS=$'\n\t' - -############################################################################### -# Globals -############################################################################### - -# $_ME -# -# This program's basename. -_ME="$(basename "${0}")" - -# $_VERSION -# -# Manually set this to to current version of the program. Adhere to the -# semantic versioning specification: http://semver.org -_VERSION="0.2.0-alpha" - -# $DEFAULT_SUBCOMMAND -# -# The subcommand to be run by default, when no subcommand name is specified. -# If the environment has an existing $DEFAULT_SUBCOMMAND set, then that value -# is used. -DEFAULT_SUBCOMMAND="${DEFAULT_SUBCOMMAND:-help}" - -############################################################################### -# Debug -############################################################################### - -# _debug() -# -# Usage: -# _debug ... -# -# Description: -# Execute a command and print to standard error. The command is expected to -# print a message and should typically be either `echo`, `printf`, or `cat`. -# -# Example: -# _debug printf "Debug info. Variable: %s\\n" "$0" -__DEBUG_COUNTER=0 -_debug() { - if ((${_USE_DEBUG:-0})) - then - __DEBUG_COUNTER=$((__DEBUG_COUNTER+1)) - { - # Prefix debug message with "bug (U+1F41B)" - printf "🐛 %s " "${__DEBUG_COUNTER}" - "${@}" - printf "―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――\\n" - } 1>&2 - fi -} - -############################################################################### -# Error Messages -############################################################################### - -# _exit_1() -# -# Usage: -# _exit_1 -# -# Description: -# Exit with status 1 after executing the specified command with output -# redirected to standard error. The command is expected to print a message -# and should typically be either `echo`, `printf`, or `cat`. -_exit_1() { - { - printf "%s " "$(tput setaf 1)!$(tput sgr0)" - "${@}" - } 1>&2 - exit 1 -} - -# _warn() -# -# Usage: -# _warn -# -# Description: -# Print the specified command with output redirected to standard error. -# The command is expected to print a message and should typically be either -# `echo`, `printf`, or `cat`. -_warn() { - { - printf "%s " "$(tput setaf 1)!$(tput sgr0)" - "${@}" - } 1>&2 -} - -############################################################################### -# Utility Functions -############################################################################### - -# _function_exists() -# -# Usage: -# _function_exists -# -# Exit / Error Status: -# 0 (success, true) If function with is defined in the current -# environment. -# 1 (error, false) If not. -# -# Other implementations, some with better performance: -# http://stackoverflow.com/q/85880 -_function_exists() { - [ "$(type -t "${1}")" == 'function' ] -} - -# _command_exists() -# -# Usage: -# _command_exists -# -# Exit / Error Status: -# 0 (success, true) If a command with is defined in the current -# environment. -# 1 (error, false) If not. -# -# Information on why `hash` is used here: -# http://stackoverflow.com/a/677212 -_command_exists() { - hash "${1}" 2>/dev/null -} - -# _contains() -# -# Usage: -# _contains ... -# -# Exit / Error Status: -# 0 (success, true) If the item is included in the list. -# 1 (error, false) If not. -# -# Examples: -# _contains "${_query}" "${_list[@]}" -_contains() { - local _query="${1:-}" - shift - - if [[ -z "${_query}" ]] || - [[ -z "${*:-}" ]] - then - return 1 - fi - - for __element in "${@}" - do - [[ "${__element}" == "${_query}" ]] && return 0 - done - - return 1 -} - -# _join() -# -# Usage: -# _join ... -# -# Description: -# Print a string containing all arguments separated by -# . -# -# Example: -# _join "${_delimeter}" "${_list[@]}" -# -# More information: -# https://stackoverflow.com/a/17841619 -_join() { - local _delimiter="${1}" - shift - printf "%s" "${1}" - shift - printf "%s" "${@/#/${_delimiter}}" | tr -d '[:space:]' -} - -# _blank() -# -# Usage: -# _blank -# -# Exit / Error Status: -# 0 (success, true) If is not present or null. -# 1 (error, false) If is present and not null. -_blank() { - [[ -z "${1:-}" ]] -} - -# _present() -# -# Usage: -# _present -# -# Exit / Error Status: -# 0 (success, true) If is present and not null. -# 1 (error, false) If is not present or null. -_present() { - [[ -n "${1:-}" ]] -} - -# _interactive_input() -# -# Usage: -# _interactive_input -# -# Exit / Error Status: -# 0 (success, true) If the current input is interactive (eg, a shell). -# 1 (error, false) If the current input is stdin / piped input. -_interactive_input() { - [[ -t 0 ]] -} - -# _piped_input() -# -# Usage: -# _piped_input -# -# Exit / Error Status: -# 0 (success, true) If the current input is stdin / piped input. -# 1 (error, false) If the current input is interactive (eg, a shell). -_piped_input() { - ! _interactive_input -} - -# exists_but_not_writable() { -# -_exists_but_not_writable() { - [[ -e "$1" ]] && ! [[ -r "$1" && -w "$1" && -x "$1" ]] -} - -# _get_local_directory() -# -_get_local_directory() { - cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P -} - -############################################################################### -# _download_from() -# -# Usage: -# _download_from [] -# -# Description: -# Download the file at and print to standard output or , if -# present. Uses `curl` if available, falling back to `wget`. Messages from -# `curl` and `wget` are suppressed. -# -# Exit / Error Status: -# 0 (success, true) If the download is successful. -# 1 (error, false) If there was an error. -# -# Examples: -# # Download and stream to standard output. -# _download_from "https://example.com" | less -# -# # Download to outfile with error handling. -# if ! _download_from "https://example.com/example.pdf" /path/to/example.pdf -# then -# printf "Download error.\\n" -# exit 1 -# fi -_download_from() { - local _downloaded=0 - local _target_path="${2:-}" - local _timeout=15 - local _url="${1:-}" - - if [[ -z "${_url}" ]] || - [[ ! "${_url}" =~ ^https\:|^http\:|^file\:|^ftp\:|^sftp\: ]] - then - return 1 - fi - - if [[ -n "${_target_path}" ]] - then - if hash "curl" 2>/dev/null - then - curl \ - --silent \ - --location \ - --connect-timeout "${_timeout}" \ - "${_url}" \ - --output "${_target_path}" \ - && _downloaded=1 - elif hash "wget" 2>/dev/null - then - wget \ - --quiet \ - --connect-timeout="${_timeout}" \ - --dns-timeout="${_timeout}" \ - -O "${_target_path}" \ - "${_url}" \ - 2>/dev/null \ - && _downloaded=1 - fi - else - if hash "curl" 2>/dev/null - then - curl \ - --silent \ - --location \ - --connect-timeout "${_timeout}" \ - "${_url}" \ - && _downloaded=1 - elif hash "wget" 2>/dev/null - then - wget \ - --quiet \ - --connect-timeout="${_timeout}" \ - --dns-timeout="${_timeout}" \ - -O - \ - "${_url}" \ - 2>/dev/null \ - && _downloaded=1 - fi - fi - - if ! ((_downloaded)) - then - return 1 - fi -} - -############################################################################### -# describe -############################################################################### - -# Set or print a description for a specified subcommand or function . The -# text can be passed as the second argument or as standard input. -# -# To make the text available to other functions, `describe()` -# assigns the text to a variable with the format `$___describe_`. -# -# When the `--get` option is used, the description for is printed, if -# one has been set. -# -# NOTE: -# -# The `read` form of assignment is used for a balance of ease of -# implementation and simplicity. There is an alternative assignment form -# that could be used here: -# -# var="$(cat <<'HEREDOC' -# some message -# HEREDOC -# ) -# -# However, this form appears to require trailing space after backslases to -# preserve newlines, which is unexpected. Using `read` simply requires -# escaping backslashes, which is more common. -describe() { - _debug printf "describe() \${*}: %s\\n" "$@" - [[ -z "${1:-}" ]] && _exit_1 printf "describe(): required.\\n" - - if [[ "${1}" == "--get" ]] - then # get ------------------------------------------------------------------ - [[ -z "${2:-}" ]] && - _exit_1 printf "describe(): required.\\n" - - local _name="${2:-}" - local _describe_var="___describe_${_name}" - - if [[ -n "${!_describe_var:-}" ]] - then - printf "%s\\n" "${!_describe_var}" - else - printf "No additional information for \`%s\`\\n" "${_name}" - fi - else # set ------------------------------------------------------------------ - if [[ -n "${2:-}" ]] - then # argument is present - read -r -d '' "___describe_${1}" <] - -Description: - Display help information for ${_ME} or a specified subcommand. -HEREDOC -help() { - if [[ "${1:-}" ]] - then - describe --get "${1}" - else - cat < [--subcommand-options] [] - ${_ME} -h | --help - ${_ME} --version - -Options: - -h --help Display this help information. - --version Display version information. - -Help: - ${_ME} help [] - -$(subcommands --) -HEREDOC - fi -} - -# subcommands ################################################################# - -describe "subcommands" <] - -Options: - -o , --hostname The API URL of GitHub. [default: api.github.com] - -t , --token Access token to revoke. [required] - -Description: - Revoke the provided access token -HEREDOC -revoke() { - local _token= - local _hostname="api.github.com" - - for __arg in "${@:-}" - do - case ${__arg} in - -t|--token) - _token=${2:-} - shift - shift - ;; - -o|--hostname) - _hostname=${2:-} - shift - shift - ;; - -*) - _exit_1 printf "Unexpected option: %s\\n" "${__arg}" - ;; - *) - esac - done - - if [[ -z "${_token}" ]] - then - _exit_1 echo "-t | --token is required" - fi - - if [[ "${_hostname}" == "api.github.com" ]] - then - - # ;------------------------------------- - # ; Get installation id from GitHub.com - # ;------------------------------------- - _api_url="https://${_hostname}" - - else - # ;------------------------------------- - # ; Get installation id from GHES - # ;------------------------------------- - _api_url="https://${_hostname}/api/v3" - - fi - - # Revoke the token - local _response= - _response=$(curl --write-out '%{http_code}' \ - --output /dev/null \ - --silent \ - -X DELETE \ - -H "Authorization: Token ""${_token}" \ - -H "Accept: application/vnd.github.v3+json" \ - "${_api_url}"/installation/token | \ - jq) - - if [[ "${_response}" == 204 ]] - then - echo "${_response}: Token revoked successfully" - else - echo "${_response}: Failed to revoke token. Maybe it expired already?" - fi -} - - - -# installations ############################################################### - -describe "installations" < | -base64_key ) --app_id [--duration ] [--hostname ] [--install_jwt_cli] - -Options: - -k , --key Path to a PEM-encoded certificate and key. [required] - -b , --base64_key Base64 encoded PEM certificate and key. [optional] - -i , --app_id GitHub App Id. [required] - -d , --duration The expiration duration of the JWT in minutes. [default: 10] - -o , --hostname The API URL of GitHub. [default: api.github.com] - -j, --install_jwt_cli Install jwt-cli (dependency) on the current system. [optional] - -Description: - Generates a JWT signed with the supplied key and fetches the list of installations -HEREDOC -installations() { - local _key= - local _app_id= - local _duration=10 - local _base64_key= - local _jwt_cli_path= - local _install_jwt_cli_q=0 - local _hostname="api.github.com" - - for __arg in "${@:-}" - do - case ${__arg} in - -k|--key) - _key=${2:-} - shift - shift - ;; - -b|--base64_key) - _base64_key=${2:-} - shift - shift - ;; - -d|--duration) - _duration=${2:-} - shift - shift - ;; - -o|--hostname) - _hostname=${2:-} - shift - shift - ;; - -i|--app_id) - _app_id=${2:-} - shift - shift - ;; - -j|--install_jwt_cli) - _install_jwt_cli_q=1 - shift - ;; - -*) - _exit_1 printf "Unexpected option: %s\\n" "${__arg}" - ;; - *) - esac - done - - if [[ ! "${_duration}" == ?(-)+([0-9]) ]] - then - _exit_1 echo "-d | --duration can only be a number" - elif [[ _duration -gt 10 ]] - then - _exit_1 echo "-d | --duration cannot be more than 10 minutes" - # If neither the key nor the base64_key string have been provided, error - elif [[ -z "${_key}" && -z "${_base64_key}" ]] - then - _exit_1 echo "-k | --key OR -b | --base64_key is required" - # If the -k | --key has been provided and the file exists and is readable - elif [[ -n "${_key}" ]] && [[ ! -e ${_key} || ! -r ${_key} ]] - then - _exit_1 echo "-k | --key not found or is not readable" - elif [[ -z "${_app_id}" ]] - then - _exit_1 echo "-i | --app_id is required" - elif [[ ! "${_app_id}" == ?(-)+([0-9]) ]] - then - _exit_1 echo "-i | --app_id can only be a number" - fi - - # ;------------------------ - # ; Install jwt-cli - # ;------------------------ - - if [[ -n "${_install_jwt_cli_q}" && ${_install_jwt_cli_q} -eq 1 ]] - then - # Set the path for jwt to be the local directory - _jwt_cli_path=$(_get_local_directory)"/jwt" - _install_jwt_cli - fi - - # ;----------------------------- - # ; Check if dependencies exist - # ;----------------------------- - - if ! _command_exists jwt && ! _command_exists "${_jwt_cli_path}" - then - _exit_1 echo "jwt-cli is required" - elif _command_exists jwt && [[ -n "${_install_jwt_cli_q}" && ${_install_jwt_cli_q} -eq 0 ]] - then - # Get the path for the existing jwt binaries - _jwt_cli_path=$(which jwt) - fi - - # ;------------------------ - # ; Generate the JWT - # ;------------------------ - - # If the key was provided as a base64 encoded string we need to decode it - # and store it in a temporary - if [[ -n "${_base64_key}" ]] - then - # Make a temporary file to contain the secret - # this file will be deleted when the script exists, crashes or is stopped - _key=$(printf "%s.pem" "$(mktemp)") - printf "%s" "$(echo "${_base64_key}" | base64 -d)" > "${_key}" - # when the script exits or is stopped with ctrl-C the file is - # still removed. - trap 'rm -f -- '"$_key" 0 2 3 15 INT EXIT - fi - - local _jwt - _jwt=$("${_jwt_cli_path}" encode -A RS256 \ - -e $(( $(date +%s) + $(( _duration * 60 )) )) \ - -i "${_app_id}" \ - -P iat=$(( $(date +%s) - 60 )) \ - -S @"${_key}") - - local _app_token= - local _api_url= - - if [[ "${_hostname}" == "api.github.com" ]] - then - - # ;------------------------------------- - # ; Get installation id from GitHub.com - # ;------------------------------------- - _api_url="https://${_hostname}" - - else - # ;------------------------------------- - # ; Get installation id from GHES - # ;------------------------------------- - _api_url="https://${_hostname}/api/v3" - - fi - - # Fetch the installation_ids - local _installations - _installations=$(curl -s \ - -H "Authorization: Bearer ""${_jwt}" \ - -H "Accept: application/vnd.github.v3+json" \ - "${_api_url}"/app/installations | \ - jq) - - echo "${_installations}" -} - -# generate #################################################################### - -describe "generate" < | --base64_key ) --app_id [--duration ] [--installation_id ] [--hostname ] [--install_jwt_cli] - -Options: - -k , --key Path to a PEM-encoded certificate and key. [required] - -b , --base64_key Base64 encoded PEM certificate and key. [optional] - -i , --app_id GitHub App Id. [required] - -d , --duration The expiration duration of the JWT in minutes. [default: 10] - -o , --hostname The API URL of GitHub. [default: api.github.com] - -j, --install_jwt_cli Install jwt-cli (dependency) on the current system. [optional] - -l , --installation_id GitHub App installation id. [default: latest id] - -Description: - Generates a JWT signed with the supplied key and fetches an - installation token -HEREDOC -generate() { - local _key= - local _app_id= - local _duration=10 - local _base64_key= - local _jwt_cli_path= - local _installation_id= - local _install_jwt_cli_q=0 - local _hostname="api.github.com" - - for __arg in "${@:-}" - do - case ${__arg} in - -k|--key) - _key=${2:-} - shift - shift - ;; - -b|--base64_key) - _base64_key=${2:-} - shift - shift - ;; - -d|--duration) - _duration=${2:-} - shift - shift - ;; - -o|--hostname) - _hostname=${2:-} - shift - shift - ;; - -i|--app_id) - _app_id=${2:-} - shift - shift - ;; - -l|--installation_id) - _installation_id=${2:-} - shift - shift - ;; - -j|--install_jwt_cli) - _install_jwt_cli_q=1 - shift - ;; - -*) - _exit_1 printf "Unexpected option: %s\\n" "${__arg}" - ;; - *) - esac - done - - if [[ ! "${_duration}" == ?(-)+([0-9]) ]] - then - _exit_1 echo "-d | --duration can only be a number" - elif [[ _duration -gt 10 ]] - then - _exit_1 echo "-d | --duration cannot be more than 10 minutes" - # If neither the key nor the base64_key string have been provided, error - elif [[ -z "${_key}" && -z "${_base64_key}" ]] - then - _exit_1 echo "-k | --key OR -b | --base64_key is required" - # If the -k | --key has been provided and the file exists and is readable - elif [[ -n "${_key}" ]] && [[ ! -e ${_key} || ! -r ${_key} ]] - then - _exit_1 echo "-k | --key not found or is not readable" - elif [[ -z "${_app_id}" ]] - then - _exit_1 echo "-i | --app_id is required" - elif [[ ! "${_app_id}" == ?(-)+([0-9]) ]] - then - _exit_1 echo "-i | --app_id can only be a number" - elif [[ -n "${_installation_id}" && ! "${_installation_id}" == ?(-)+([0-9]) ]] - then - _exit_1 echo "-l | --installation_id can only be a number" - fi - - # ;------------------------ - # ; Install jwt-cli - # ;------------------------ - - if [[ -n "${_install_jwt_cli_q}" && ${_install_jwt_cli_q} -eq 1 ]] - then - # Set the path for jwt to be the local directory - _jwt_cli_path=$(_get_local_directory)"/jwt" - _install_jwt_cli - fi - - # ;----------------------------- - # ; Check if dependencies exist - # ;----------------------------- - - if ! _command_exists jwt && ! _command_exists "${_jwt_cli_path}" - then - _exit_1 echo "jwt-cli is required" - elif _command_exists jwt && [[ -n "${_install_jwt_cli_q}" && ${_install_jwt_cli_q} -eq 0 ]] - then - # Get the path for the existing jwt binaries - _jwt_cli_path=$(which jwt) - fi - - # ;------------------------ - # ; Generate the JWT - # ;------------------------ - - # If the key was provided as a base64 encoded string we need to decode it - # and store it in a temporary - if [[ -n "${_base64_key}" ]] - then - # Make a temporary file to contain the secret - # this file will be deleted when the script exists, crashes or is stopped - _key=$(printf "%s.pem" "$(mktemp)") - printf "%s" "$(echo "${_base64_key}" | base64 -d)" > "${_key}" - # when the script exits or is stopped with ctrl-C the file is - # still removed. - trap 'rm -f -- '"$_key" 0 2 3 15 INT EXIT - fi - - local _jwt - _jwt=$("${_jwt_cli_path}" encode -A RS256 \ - -e $(( $(date +%s) + $(( _duration * 60 )) )) \ - -i "${_app_id}" \ - -P iat=$(( $(date +%s) - 60 )) \ - -S @"${_key}") - - local _app_token= - local _api_url= - - if [[ "${_hostname}" == "api.github.com" ]] - then - - # ;------------------------------------- - # ; Get installation id from GitHub.com - # ;------------------------------------- - _api_url="https://${_hostname}" - - else - # ;------------------------------------- - # ; Get installation id from GHES - # ;------------------------------------- - _api_url="https://${_hostname}/api/v3" - - fi - - # Fetch the latest installation_id only if it wasn't supplied - if [[ -z "${_installation_id}" ]] - then - _installation_id=$(curl -s \ - -H "Authorization: Bearer ""${_jwt}" \ - -H "Accept: application/vnd.github.v3+json" \ - "${_api_url}"/app/installations | \ - jq -r 'try .[0].id catch "failed"') - - if [[ "${_installation_id}" == "failed" ]] - then - _exit_1 echo "failed to fetch installation id" - fi - fi - - # Create the installation access token - _app_token=$(curl -s \ - -X POST \ - -H "Authorization: Bearer ""${_jwt}" \ - -H "Accept: application/vnd.github.v3+json" \ - "${_api_url}"/app/installations/"${_installation_id}"/access_tokens | \ - jq -r 'try {"token": .token, "expires_at": .expires_at} catch "failed"') - - if [[ "${_app_token}" == "failed" ]] - then - _exit_1 echo "failed to create app token" - fi - - echo "${_app_token}" -} - -############################################################################### -# Run Program -############################################################################### - -# Call the `_main` function after everything has been defined. -_main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f782a77 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/Link-/gh-token + +go 1.20 + +require ( + github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/google/go-github/v55 v55.0.0 + github.com/urfave/cli/v2 v2.25.7 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/sys v0.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..087cf13 --- /dev/null +++ b/go.sum @@ -0,0 +1,35 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/images/gh-token.png b/images/gh-token.png new file mode 100644 index 0000000..a01765e Binary files /dev/null and b/images/gh-token.png differ diff --git a/images/ghtoken.png b/images/ghtoken.png deleted file mode 100644 index 4508de4..0000000 Binary files a/images/ghtoken.png and /dev/null differ diff --git a/internal/generate.go b/internal/generate.go new file mode 100644 index 0000000..e91fd40 --- /dev/null +++ b/internal/generate.go @@ -0,0 +1,232 @@ +package internal + +import ( + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/google/go-github/v55/github" + "github.com/urfave/cli/v2" +) + +// Generate is the entrypoint for the generate command +func Generate(c *cli.Context) error { + appID := c.String("app-id") + installationID := c.String("installation-id") + keyPath := c.String("key") + keyBase64 := c.String("key-base64") + printJWT := c.Bool("jwt") + jwtExpiry := c.Int("jwt-expiry") + hostname := strings.ToLower(c.String("hostname")) + tokenOnly := c.Bool("token-only") + silent := c.Bool("silent") + + if keyPath == "" && keyBase64 == "" { + return fmt.Errorf("either --key or --key-base64 must be specified") + } + + if keyPath != "" && keyBase64 != "" { + return fmt.Errorf("only one of --key or --key-base64 may be specified") + } + + if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { + endpoint := fmt.Sprintf("%s/api/v3", hostname) + hostname = strings.TrimSuffix(endpoint, "/") + } + + if jwtExpiry < 1 || jwtExpiry > 10 { + return fmt.Errorf("jwt-expiry must be between 1 and 10") + } + + var err error + var privateKey *rsa.PrivateKey + if keyPath != "" { + privateKey, err = readKey(keyPath) + if err != nil { + return err + } + } else { + privateKey, err = readKeyBase64(keyBase64) + if err != nil { + return err + } + } + + jsonWebToken, err := generateJWT(appID, jwtExpiry, privateKey) + if err != nil { + return fmt.Errorf("failed generating JWT: %w", err) + } + + if printJWT { + if !silent { + fmt.Println(jsonWebToken) + } + return nil + } + + if installationID == "" { + installationID, err = retrieveDefaultInstallationID(hostname, jsonWebToken) + if err != nil { + return fmt.Errorf("failed retrieving default installation ID: %w", err) + } + } + + token, err := generateToken(hostname, jsonWebToken, installationID) + if err != nil { + return fmt.Errorf("failed generating installation token: %w", err) + } + + if !silent { + bytes, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token to JSON: %w", err) + } + + if tokenOnly { + fmt.Println(*token.Token) + } else { + fmt.Println(string(bytes)) + } + } + + return nil +} + +func retrieveDefaultInstallationID(hostname, jwt string) (string, error) { + endpoint := fmt.Sprintf("https://%s/app/installations?per_page=1", hostname) + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return "", fmt.Errorf("unable to create GET request to %s: %w", endpoint, err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("X-GitHub-Api-Version", "2022-11-28") + req.Header.Add("User-Agent", "Link-/gh-token") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("unable to POST to %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response []github.Installation + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("unable to read response body: %w", err) + } + + err = json.Unmarshal(bytes, &response) + if err != nil { + return "", fmt.Errorf("unable to unmarshal response body: %w", err) + } + + return strconv.FormatInt(*response[0].ID, 10), nil +} + +func generateToken(hostname, jwt, installationID string) (*github.InstallationToken, error) { + endpoint := fmt.Sprintf("https://%s/app/installations/%s/access_tokens", hostname, installationID) + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("unable to create POST request to %s: %w", endpoint, err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("X-GitHub-Api-Version", "2022-11-28") + req.Header.Add("User-Agent", "Link-/gh-token") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 201 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response *github.InstallationToken + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("unable to read response body: %w", err) + } + + err = json.Unmarshal(bytes, &response) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response body: %w", err) + } + + return response, nil +} + +// GenerateFlags returns the CLI flags for the generate command +func GenerateFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "app-id", + Usage: "GitHub App ID", + Required: true, + Aliases: []string{"a"}, + }, + &cli.StringFlag{ + Name: "installation-id", + Usage: "GitHub App installation ID. Defaults to the first installation returned by the GitHub API if not specified", + Required: false, + Aliases: []string{"i"}, + }, + &cli.StringFlag{ + Name: "key", + Usage: "Path to private key", + Required: false, + Aliases: []string{"k"}, + }, + &cli.StringFlag{ + Name: "key-base64", + Usage: "A base64 encoded private key", + Required: false, + Aliases: []string{"b"}, + }, + &cli.StringFlag{ + Name: "hostname", + Usage: "GitHub Enterprise Server API endpoint, example: github.example.com/api/v3", + Required: false, + Aliases: []string{"o"}, + Value: "api.github.com", + }, + &cli.BoolFlag{ + Name: "token-only", + Usage: "Only print the token to stdout, not the full JSON response, useful for piping to other commands", + Aliases: []string{"t"}, + Value: false, + }, + &cli.BoolFlag{ + Name: "jwt", + Usage: "Return the JWT instead of generating an installation token, useful for calling API's requiring a JWT", + Required: false, + Aliases: []string{"j"}, + Value: false, + }, + &cli.IntFlag{ + Name: "jwt-expiry", + Usage: "The expiry time of the JWT in minutes up to a maximum value of 10, useful when using the --jwt flag", + Required: false, + Aliases: []string{"e"}, + Value: 1, + }, + &cli.BoolFlag{ + Name: "silent", + Usage: "Do not print token to stdout", + Aliases: []string{"s"}, + Value: false, + }, + } +} diff --git a/internal/installations.go b/internal/installations.go new file mode 100644 index 0000000..67eae7b --- /dev/null +++ b/internal/installations.go @@ -0,0 +1,147 @@ +package internal + +import ( + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/go-github/v55/github" + "github.com/urfave/cli/v2" +) + +// Installations is the entrypoint for the installations command +func Installations(c *cli.Context) error { + appID := c.String("app-id") + keyPath := c.String("key") + keyBase64 := c.String("key-base64") + hostname := strings.ToLower(c.String("hostname")) + + if keyPath == "" && keyBase64 == "" { + return fmt.Errorf("either --key or --key-base64 must be specified") + } + + if keyPath != "" && keyBase64 != "" { + return fmt.Errorf("only one of --key or --key-base64 may be specified") + } + + if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { + endpoint := fmt.Sprintf("%s/api/v3", hostname) + hostname = strings.TrimSuffix(endpoint, "/") + } + + var err error + var privateKey *rsa.PrivateKey + if keyPath != "" { + privateKey, err = readKey(keyPath) + if err != nil { + return err + } + } else { + privateKey, err = readKeyBase64(keyBase64) + if err != nil { + return err + } + } + + jsonWebToken, err := generateJWT(appID, 1, privateKey) + if err != nil { + return fmt.Errorf("failed generating JWT: %w", err) + } + + installations, err := listInstallations(hostname, jsonWebToken) + if err != nil { + return fmt.Errorf("failed listing installations: %w", err) + } + + bytes, err := json.MarshalIndent(installations, "", " ") + if err != nil { + return fmt.Errorf("failed marshalling installations to JSON: %w", err) + } + + fmt.Println(string(bytes)) + + return nil +} + +func listInstallations(hostname, jwt string) (*[]github.Installation, error) { + var responses []github.Installation + page := 0 + for { + endpoint := fmt.Sprintf("https://%s/app/installations?per_page=100&page=%d", hostname, page) + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("unable to create GET request to %s: %w", endpoint, err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("X-GitHub-Api-Version", "2022-11-28") + req.Header.Add("User-Agent", "Link-/gh-token") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("unable to POST to %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response *[]github.Installation + bytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("unable to read response body: %w", err) + } + + err = json.Unmarshal(bytes, &response) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal response body: %w", err) + } + responses = append(responses, *response...) + + if len(*response) < 100 { + break + } + page++ + + time.Sleep(1 * time.Second) + } + + return &responses, nil +} + +// InstallationsFlags returns the CLI flags for the generate command +func InstallationsFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "app-id", + Usage: "GitHub App ID", + Required: true, + Aliases: []string{"a"}, + }, + &cli.StringFlag{ + Name: "key", + Usage: "Path to private key", + Required: false, + Aliases: []string{"k"}, + }, + &cli.StringFlag{ + Name: "key-base64", + Usage: "A base64 encoded private key", + Required: false, + Aliases: []string{"b"}, + }, + &cli.StringFlag{ + Name: "hostname", + Usage: "GitHub Enterprise Server API endpoint, example: github.example.com/api/v3", + Required: false, + Aliases: []string{"o"}, + Value: "api.github.com", + }, + } +} diff --git a/internal/key.go b/internal/key.go new file mode 100644 index 0000000..3eb143f --- /dev/null +++ b/internal/key.go @@ -0,0 +1,53 @@ +package internal + +import ( + "crypto/rsa" + "encoding/base64" + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func readKey(path string) (*rsa.PrivateKey, error) { + keyBytes, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("unable to read key file: %w", err) + } + key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err) + } + + return key, nil +} + +func readKeyBase64(keyBase64 string) (*rsa.PrivateKey, error) { + keyBytes, err := base64.StdEncoding.DecodeString(keyBase64) + if err != nil { + return nil, fmt.Errorf("unable to decode key from base64: %w", err) + } + key, err := jwt.ParseRSAPrivateKeyFromPEM(keyBytes) + if err != nil { + return nil, fmt.Errorf("unable to parse key from PEM to RSA format: %w", err) + } + + return key, nil +} + +func generateJWT(appID string, expiry int, key *rsa.PrivateKey) (string, error) { + iat := jwt.NewNumericDate(time.Now().Add(-60 * time.Second)) + exp := jwt.NewNumericDate(time.Now().Add(time.Duration(expiry) * 60 * time.Second)) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iat": iat, + "exp": exp, + "iss": appID, + }) + signedToken, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("unable to sign JWT: %w", err) + } + + return signedToken, nil +} diff --git a/internal/revoke.go b/internal/revoke.go new file mode 100644 index 0000000..6acbd1f --- /dev/null +++ b/internal/revoke.go @@ -0,0 +1,81 @@ +package internal + +import ( + "fmt" + "net/http" + "strings" + + "github.com/urfave/cli/v2" +) + +// Revoke is the entrypoint for the revoke command +func Revoke(c *cli.Context) error { + token := c.String("token") + hostname := strings.ToLower(c.String("hostname")) + silent := c.Bool("silent") + + if hostname != "api.github.com" && !strings.Contains(hostname, "/api/v3") { + endpoint := fmt.Sprintf("%s/api/v3", hostname) + hostname = strings.TrimSuffix(endpoint, "/") + } + + err := revokeToken(hostname, token) + if err != nil { + return fmt.Errorf("failed revoking installation token: %w", err) + } + if !silent { + fmt.Println("Successfully revoked installation token") + } + + return nil +} + +func revokeToken(hostname, token string) error { + endpoint := fmt.Sprintf("https://%s/installation/token", hostname) + req, err := http.NewRequest("DELETE", endpoint, nil) + if err != nil { + return fmt.Errorf("unable to create DELETE request to %s: %w", endpoint, err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + req.Header.Add("Accept", "application/vnd.github+json") + req.Header.Add("X-GitHub-Api-Version", "2022-11-28") + req.Header.Add("User-Agent", "Link-/gh-token") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("unable to DELETE to %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != 204 { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// RevokeFlags returns the CLI flags for the revoke command +func RevokeFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "token", + Usage: "GitHub App installation Token", + Required: true, + Aliases: []string{"t"}, + }, + &cli.StringFlag{ + Name: "hostname", + Usage: "GitHub Enterprise Server API endpoint, example: github.example.com/api/v3", + Required: false, + Aliases: []string{"o"}, + Value: "api.github.com", + }, + &cli.BoolFlag{ + Name: "silent", + Usage: "Do not print to stdout", + Aliases: []string{"s"}, + Value: false, + }, + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..51d9579 --- /dev/null +++ b/main.go @@ -0,0 +1,46 @@ +// Package main is the entrypoint for the gh-token CLI +package main + +import ( + "fmt" + "os" + + "github.com/Link-/gh-token/internal" + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "gh-token", + Usage: "Manage GitHub App installation tokens", + Version: "2.0.0", + EnableBashCompletion: true, + Suggest: true, + Commands: []*cli.Command{ + { + Name: "generate", + Usage: "Generate a new GitHub App installation token", + Flags: internal.GenerateFlags(), + Action: internal.Generate, + }, + { + Name: "revoke", + Usage: "Revoke a GitHub App installation token", + Flags: internal.RevokeFlags(), + Action: internal.Revoke, + }, + { + Name: "installations", + Usage: "List GitHub App installations", + Flags: internal.InstallationsFlags(), + Action: internal.Installations, + }, + }, + } + + err := app.Run(os.Args) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } +}