├── entrypoint.sh ├── .whitesource ├── prebuild.Dockerfile ├── action.yml ├── .github └── workflows │ └── test.yaml ├── README.md └── token_getter.py /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo $INPUT_APP_PEM | base64 -d > pem.txt 4 | python /app/token_getter.py 5 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "checkRunSettings": { 3 | "vulnerableCheckRunConclusionLevel": "failure" 4 | }, 5 | "issueSettings": { 6 | "minSeverityLevel": "LOW" 7 | } 8 | } -------------------------------------------------------------------------------- /prebuild.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-slim-stretch 2 | 3 | RUN pip install \ 4 | cryptography==2.6.1 \ 5 | github3.py==1.3.0 \ 6 | jwcrypto==0.6.0 \ 7 | pyjwt==1.7.1 8 | 9 | COPY token_getter.py app/ 10 | COPY entrypoint.sh app/ 11 | RUN chmod u+x app/entrypoint.sh 12 | WORKDIR app/ 13 | 14 | CMD /app/entrypoint.sh 15 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Get an app token in an Actions workflow.' 2 | description: Useful for remedying the problem of restricted access tokens, especially on PRs from forks. 3 | author: Hamel Husain 4 | inputs: 5 | APP_PEM: 6 | description: a base64 encoded string version of your PEM file used to authenticate as a GitHub App. You can apply this encoding in the terminal `cat key.pem | base64` 7 | required: true 8 | APP_ID: 9 | description: you GITHUB App ID. 10 | required: true 11 | outputs: 12 | app_token: 13 | description: The installation access token for the GitHub App corresponding to and the current repository. 14 | branding: 15 | color: 'white' 16 | icon: 'unlock' 17 | runs: 18 | using: 'docker' 19 | image: 'docker://hamelsmu/app-token' 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push] 3 | 4 | jobs: 5 | build-temp-container: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | 10 | - name: build-temp-container 11 | run: | 12 | echo ${PASSWORD} | docker login -u $USERNAME --password-stdin 13 | docker build -t hamelsmu/app-token:temp -f prebuild.Dockerfile . 14 | docker push hamelsmu/app-token:temp 15 | env: 16 | USERNAME: ${{ secrets.DOCKER_USERNAME }} 17 | PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 18 | 19 | test-container: 20 | needs: [build-temp-container] 21 | runs-on: ubuntu-latest 22 | steps: 23 | 24 | - uses: actions/checkout@master 25 | 26 | # - name: Setup tmate session 27 | # uses: mxschmitt/action-tmate@v1 28 | # env: 29 | # INPUT_APP_PEM: ${{ secrets.APP_PEM }} 30 | # INPUT_APP_ID: ${{ secrets.APP_ID }} 31 | 32 | # tested with https://github.com/apps/fastpages-chatops 33 | - name: test 34 | id: test 35 | uses: docker://hamelsmu/app-token:temp 36 | env: 37 | INPUT_APP_PEM: ${{ secrets.APP_PEM }} 38 | INPUT_APP_ID: ${{ secrets.APP_ID }} 39 | 40 | - name: pre-build action image 41 | run: | 42 | cd $GITHUB_WORKSPACE 43 | echo ${PASSWORD} | docker login -u $USERNAME --password-stdin 44 | docker build -t hamelsmu/app-token -f prebuild.Dockerfile . 45 | docker push hamelsmu/app-token 46 | env: 47 | USERNAME: ${{ secrets.DOCKER_USERNAME }} 48 | PASSWORD: ${{ secrets.DOCKER_PASSWORD }} 49 | 50 | # tested withhttps://github.com/apps/fastpages-chatops 51 | - name: final-test 52 | uses: machine-learning-apps/actions-app-token@master 53 | with: 54 | APP_PEM: ${{ secrets.APP_PEM }} 55 | APP_ID: ${{ secrets.APP_ID }} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Actions Status](https://github.com/machine-learning-apps/actions-app-token/workflows/Tests/badge.svg) 2 | 3 | # Impersonate Your GitHub App In A GitHub Action 4 | 5 | This action helps you retrieve an authenticated app token with a GitHub app id and a app private key. You can use this key inside an actions workflow instead of `GITHUB_TOKEN`, in cases where the `GITHUB_TOKEN` has restricted rights. 6 | 7 | ## Why Would You Do This? 8 | 9 | 10 | Actions have certain limitations. Many of these limitations are for security and stability reasons, however not all of them are. Some examples where you might want to impersonate a GitHub App temporarily in your workflow: 11 | 12 | - You want an [event to trigger a workflow](https://help.github.com/en/articles/events-that-trigger-workflows) on a specific ref or branch in a way that is not natively supported by Actions. For example, a pull request comment fires the [issue_comment event](https://help.github.com/en/articles/events-that-trigger-workflows#issue-comment-event-issue_comment) which is sent to the default branch and not the PR's branch. You can temporarily impersonate a GitHub App to make an event, such as a [label a pull_request](https://help.github.com/en/articles/events-that-trigger-workflows#pull-request-event-pull_request) to trigger a workflow on the right branch. This takes advantage of the fact that Actions cannot create events that trigger workflows, however other Apps can. 13 | 14 | # Usage 15 | 16 | 1. If you do not already own a GitHub App you want to impersonate, [create a new GitHub App](https://developer.github.com/apps/building-github-apps/creating-a-github-app/) with your desired permissions. If only creating a new app for the purposes of impersonation by Actions, you do not need to provide a `Webhook URL or Webhook Secret` 17 | 18 | 2. Install the App on your repositories. 19 | 20 | 3. See [action.yml](action.yml) for the api spec. 21 | 22 | Example: 23 | 24 | ```yaml 25 | steps: 26 | - name: Get token 27 | id: get_token 28 | uses: machine-learning-apps/actions-app-token@master 29 | with: 30 | APP_PEM: ${{ secrets.APP_PEM }} 31 | APP_ID: ${{ secrets.APP_ID }} 32 | 33 | - name: Get App Installation Token 34 | run: | 35 | echo "This token is masked: ${TOKEN}" 36 | env: 37 | TOKEN: ${{ steps.get_token.outputs.app_token }} 38 | ``` 39 | 40 | **Note: The input `APP_PEM` needs to be base64 encoded.** You can encode your private key file like this from the terminal: 41 | 42 | ``` 43 | cat your_app_key.pem | base64 -w 0 && echo 44 | ``` 45 | *The base64 encoded string must be on a single line, so be sure to remove any linebreaks when creating `APP_PEM` in your project's GitHub secrets.* 46 | 47 | ## Mandatory Inputs 48 | 49 | - `APP_PEM`: description: string version of your PEM file used to authenticate as a GitHub App. 50 | 51 | - `APP_ID`: your GitHub App ID. 52 | 53 | ## Outputs 54 | 55 | - `app_token`: The [installation access token](https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-an-installation) for the GitHub App corresponding to the current repository. 56 | 57 | 58 | # License 59 | 60 | The scripts and documentation in this project are released under the MIT License. 61 | -------------------------------------------------------------------------------- /token_getter.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple, Counter 2 | from github3 import GitHub 3 | from pathlib import Path 4 | from cryptography.hazmat.backends import default_backend 5 | import time 6 | import json 7 | import jwt 8 | import requests 9 | from typing import List 10 | import os 11 | 12 | class GitHubApp(GitHub): 13 | """ 14 | This is a small wrapper around the github3.py library 15 | 16 | Provides some convenience functions for testing purposes. 17 | """ 18 | 19 | def __init__(self, pem_path, app_id, nwo): 20 | super().__init__() 21 | self.app_id = app_id 22 | 23 | self.path = Path(pem_path) 24 | self.app_id = app_id 25 | if not self.path.is_file(): 26 | raise ValueError(f'argument: `pem_path` must be a valid filename. {pem_path} was not found.') 27 | self.nwo = nwo 28 | 29 | def get_app(self): 30 | with open(self.path, 'rb') as key_file: 31 | client = GitHub() 32 | client.login_as_app(private_key_pem=key_file.read(), 33 | app_id=self.app_id) 34 | return client 35 | 36 | def get_installation(self, installation_id): 37 | "login as app installation without requesting previously gathered data." 38 | with open(self.path, 'rb') as key_file: 39 | client = GitHub() 40 | client.login_as_app_installation(private_key_pem=key_file.read(), 41 | app_id=self.app_id, 42 | installation_id=installation_id) 43 | return client 44 | 45 | def get_test_installation_id(self): 46 | "Get a sample test_installation id." 47 | client = self.get_app() 48 | return next(client.app_installations()).id 49 | 50 | def get_test_installation(self): 51 | "login as app installation with the first installation_id retrieved." 52 | return self.get_installation(self.get_test_installation_id()) 53 | 54 | def get_test_repo(self): 55 | repo = self.get_all_repos(self.get_test_installation_id())[0] 56 | appInstallation = self.get_test_installation() 57 | owner, name = repo['full_name'].split('/') 58 | return appInstallation.repository(owner, name) 59 | 60 | def get_test_issue(self): 61 | test_repo = self.get_test_repo() 62 | return next(test_repo.issues()) 63 | 64 | def get_jwt(self): 65 | """ 66 | This is needed to retrieve the installation access token (for debugging). 67 | 68 | Useful for debugging purposes. Must call .decode() on returned object to get string. 69 | """ 70 | now = self._now_int() 71 | payload = { 72 | "iat": now, 73 | "exp": now + (60), 74 | "iss": self.app_id 75 | } 76 | with open(self.path, 'rb') as key_file: 77 | private_key = default_backend().load_pem_private_key(key_file.read(), None) 78 | return jwt.encode(payload, private_key, algorithm='RS256') 79 | 80 | def get_installation_id(self): 81 | "https://developer.github.com/v3/apps/#find-repository-installation" 82 | 83 | owner, repo = self.nwo.split('/') 84 | 85 | url = f'https://api.github.com/repos/{owner}/{repo}/installation' 86 | 87 | headers = {'Authorization': f'Bearer {self.get_jwt().decode()}', 88 | 'Accept': 'application/vnd.github.machine-man-preview+json'} 89 | 90 | response = requests.get(url=url, headers=headers) 91 | if response.status_code != 200: 92 | raise Exception(f'Status code : {response.status_code}, {response.json()}') 93 | return response.json()['id'] 94 | 95 | def get_installation_access_token(self, installation_id): 96 | "Get the installation access token for debugging." 97 | 98 | url = f'https://api.github.com/app/installations/{installation_id}/access_tokens' 99 | headers = {'Authorization': f'Bearer {self.get_jwt().decode()}', 100 | 'Accept': 'application/vnd.github.machine-man-preview+json'} 101 | 102 | response = requests.post(url=url, headers=headers) 103 | if response.status_code != 201: 104 | raise Exception(f'Status code : {response.status_code}, {response.json()}') 105 | return response.json()['token'] 106 | 107 | def _extract(self, d, keys): 108 | "extract selected keys from a dict." 109 | return dict((k, d[k]) for k in keys if k in d) 110 | 111 | def _now_int(self): 112 | return int(time.time()) 113 | 114 | def get_all_repos(self, installation_id): 115 | """Get all repos that this installation has access to. 116 | 117 | Useful for testing and debugging. 118 | """ 119 | url = 'https://api.github.com/installation/repositories' 120 | headers={'Authorization': f'token {self.get_installation_access_token(installation_id)}', 121 | 'Accept': 'application/vnd.github.machine-man-preview+json'} 122 | 123 | response = requests.get(url=url, headers=headers) 124 | 125 | if response.status_code >= 400: 126 | raise Exception(f'Status code : {response.status_code}, {response.json()}') 127 | 128 | fields = ['name', 'full_name', 'id'] 129 | return [self._extract(x, fields) for x in response.json()['repositories']] 130 | 131 | 132 | def generate_installation_curl(self, endpoint): 133 | iat = self.get_installation_access_token() 134 | print(f'curl -i -H "Authorization: token {iat}" -H "Accept: application/vnd.github.machine-man-preview+json" https://api.github.com{endpoint}') 135 | 136 | if __name__ == '__main__': 137 | 138 | pem_path = 'pem.txt' 139 | app_id = os.getenv('INPUT_APP_ID') 140 | nwo = os.getenv('GITHUB_REPOSITORY') 141 | 142 | assert pem_path, 'Must supply input APP_PEM' 143 | assert app_id, 'Must supply input APP_ID' 144 | assert nwo, "The environment variable GITHUB_REPOSITORY was not found." 145 | 146 | app = GitHubApp(pem_path=pem_path, app_id=app_id, nwo=nwo) 147 | id = app.get_installation_id() 148 | token = app.get_installation_access_token(installation_id=id) 149 | assert token, 'Token not returned!' 150 | 151 | print(f"::add-mask::{token}") 152 | print(f"::set-output name=app_token::{token}") --------------------------------------------------------------------------------