Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add githubapp auth #1

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,40 @@ Draft Example
## Getting Started
Chronicler is a simple express.js app that receives GitHub Webhook events via its `/webhooks` route. You'll need to clone and set the app up on a server or cloud service (e.x. Google Cloud Platform, AWS, Digital Ocean) to use it for your own projects.

### Setup

#### Environment Variables
### Environment Variables
The following variables must be set up and available to Chronicler via the node.js `process.env` object.
It is possible to run both as a Github app or as an individual user.
To run as a Github app: SET AUTH_AS_APP=true, APP_IDENTIFIER and SECRET to their respective values. GH_TOKEN can then be omitted. **Also make sure to set the private key for the app (listed under basic information about the app) in /run/secrets/private-key**
To run the service as a user: SET GH_TOKEN and SECRET to their respective values. AUTH_AS_APP and PRIVATE_KEY, APP_IDENTIFIER can then be omitted.

**Variable Name** | **Description** | **Default**
--- | --- | :---:
`GH_TOKEN` | The Github [personal access token](https://github.com/settings/tokens) to use for this app. Used for authentication when making calls to the GitHub API. | -
`SECRET` | The GitHub Webhook secret passed along with every Webhooks request. Allows your app to authenticate the request and make sure the request is coming from a trusted source. Generate a [random string with high entropy](https://developer.github.com/webhooks/securing/#setting-your-secret-token) for your secure secret or create one using an online [generator](https://randomkeygen.com/). | -
`SECRET` (required) | The GitHub Webhook secret passed along with every Webhooks request. Allows your app to authenticate the request and make sure the request is coming from a trusted source. Generate a [random string with high entropy](https://developer.github.com/webhooks/securing/#setting-your-secret-token) for your secure secret or create one using an online [generator](https://randomkeygen.com/). | -
`AUTH_AS_APP` (required if github app)| Whether to authenticate as a Github app | false
`APP_IDENTIFIER` (required if github app)| The unique identifier for the Github app, listed under basic information about the app | -
`GH_TOKEN` (required if set as a user)| The Github [personal access token](https://github.com/settings/tokens) to use for this app. Used for authentication when making calls to the GitHub API. | -
`APP_NAME` (optional) | Name of the app to send as the `User-Agent` value in the API requests. | `Chronicler`
`PORT` (optional) | App port. | `8080`

### Setup as Github app

#### Creating the app
A Github app can be created on both a user and an organization. The latter is recommended when working together with a team whose members might change. Thus the service is uncoupled from a specific user that might leave.
To set it up, go to https://github.com/organizations/`<organization>`/settings/apps and create new. Fill out the required fields and give it the following permissions:

1. Repository contents **Read & Write**
2. Repository metadata **Read**
3. Pull requests **Read**
- Subscribe to events: Pull Requests

When created, go to the general-tab for the app and collect set the **Webhook secret (SECRET)** the **PRIVATE_KEY** and **APP_IDENTIFIER** to be set in your environment.

Lastly. Install the app on your organization/repository through the organization/repository settings

### Setup as User

##### A Note on Personal Access Tokens
Chronicler requires a personal access token (PAT) to create or edit a release draft via the GitHub API. PATs are tied to a user's account. For GitHub teams or organizations using Chronicler we reccommend creating a dedicated GitHub account that owns the PAT. By creating the PAT with a dedicated GitHub account instead of with a team member's account, you can avoid interuptions to Chronicler if the team member leaves or is removed from the organization.
When setting up Chronicler as a user it requires a personal access token (PAT) to create or edit a release draft via the GitHub API. PATs are tied to a user's account. For GitHub teams or organizations using Chronicler we reccommend creating a dedicated GitHub account that owns the PAT. By creating the PAT with a dedicated GitHub account instead of with a team member's account, you can avoid interuptions to Chronicler if the team member leaves or is removed from the organization.

To generate a new PAT for Chronicler, go to your [account settings](https://github.com/settings/tokens/new). Add a "token description" (e.x "chronicler-app") and grant it `repo` scope.

Expand Down
2 changes: 1 addition & 1 deletion __tests__/auth.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test from 'ava'
import auth from '../src/helpers/auth'
import { auth } from '../src/auth/webhook'
import { mockRequest } from './fixtures/webhook-event'

// fake request object for auth test
Expand Down
37 changes: 37 additions & 0 deletions __tests__/githubApp.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import test from 'ava'
import sinon from 'sinon'
import proxyquire from 'proxyquire'

let githubApp, signStub, momentStub
test.beforeEach(() => {
process.env.APP_IDENTIFIER = 'MOCKED_APP_IDENTIFIER'
const fs = {
readFileSync: sinon.stub().returns('MOCKED_PRIVATE_KEY')
}
signStub = sinon.stub().returns('jwt123')
momentStub = {
unix: sinon.stub()
}
momentStub.add = () => momentStub
githubApp = proxyquire('../src/auth/githubApp.js', {
'jsonwebtoken': {
sign: signStub
},
'moment': () => momentStub,
'fs': fs
})
})

test('calls jwt encode with iat, exp, iss', t => {
momentStub.unix.onFirstCall().returns(1)
momentStub.unix.onSecondCall().returns(2)
t.is(githubApp.auth(), 'jwt123')

t.true(signStub.calledWith({
iss: 'MOCKED_APP_IDENTIFIER',
iat: 1,
exp: 2,
}, 'MOCKED_PRIVATE_KEY', {
algorithm: 'RS256'
}))
})
30 changes: 30 additions & 0 deletions __tests__/installation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'ava'
import sinon from 'sinon'
import proxyquire from 'proxyquire'

let installation, axiosStub
test.beforeEach(() => {
axiosStub = sinon.stub().resolves({
data: {
token: 'accesstokenfromgithub'
}
})
installation = proxyquire('../src/auth/installation.js', {
'axios': axiosStub
})
})

test('requests bearer token with installation ID and jwt', async t => {
const id = 5
const req = { body: { installation: { id } } }

t.is(await installation.auth(req, 'jwttokenstring'), 'accesstokenfromgithub')
t.true(axiosStub.calledWith({
url: `https://api.github.com/app/installations/${id}/access_tokens`,
headers: {
Authorization: 'Bearer ' + 'jwttokenstring',
Accept: 'application/vnd.github.machine-man-preview+json'
},
method: 'POST'
}))
})
28 changes: 11 additions & 17 deletions __tests__/pr.test.js
Original file line number Diff line number Diff line change
@@ -1,49 +1,43 @@
import test from 'ava'
import webhookData from './fixtures/webhook-event'
import { drafts } from './fixtures/releases'
import {
isTooOld,
getReleasesUrl,
getSingleReleaseUrl,
getPrDesc,
getPrData,
updateReleaseDraft
} from '../src/helpers/pr'

const pr = getPrData(webhookData)
const draft = drafts[0]
import WebhookHandler from '../src/helpers/pr'

const draft = drafts[0]
let prHelper, pr
test.beforeEach(() => {
process.env.GH_TOKEN = 'MOCK_TOKEN'
prHelper = new WebhookHandler('MOCK_TOKEN')
pr = prHelper.getPrData(webhookData)
})

test('isTooOld returns true if PR was merged more than 5 minutes ago', t => {
t.true(isTooOld(pr.merged_at))
t.true(prHelper.isTooOld(pr.merged_at))
})

test('isTooOld returns false if PR was merged within 5 minutes', t => {
// the current time
t.false(isTooOld(new Date()))
t.false(prHelper.isTooOld(new Date()))
})

test('getReleasesUrl returns the url for the github repo releases endpoint', t => {
const expected =
'https://api.github.com/repos/NYTimes/Chronicler/releases?access_token=MOCK_TOKEN'

t.is(getReleasesUrl(pr), expected)
t.is(prHelper.getReleasesUrl(pr), expected)
})

test('getSingleReleaseUrl returns the github release url for a given release id', t => {
const expected =
'https://api.github.com/repos/NYTimes/Chronicler/releases/9797693?access_token=MOCK_TOKEN'

t.is(getSingleReleaseUrl(pr, draft), expected)
t.is(prHelper.getSingleReleaseUrl(pr, draft), expected)
})

test('getPrDesc should return the formatted description for a pull request with title and number', t => {
const expected = '- Update README.md (#16)'

t.is(getPrDesc(pr), expected)
t.is(prHelper.getPrDesc(pr), expected)
})

test('getPrData should return a pull request object with the repository url', t => {
Expand All @@ -57,7 +51,7 @@ test('getPrData should return a pull request object with the repository url', t
test('updateReleaseDraft should append the pull request title and number to existing draft', t => {
const expect =
'- Title Change (#4) - Give Props (#3) - Test permissions (#6) - Another Permissions test (#7) - Update README.md (#10) - Update README.md (#12) - Update README.md (#13) - Update README.md (#14) - Update README.md (#15) - Update README.md (#16) - Update README.md (#16) - Add webhook url to readme (#5)\n- Update README.md (#16)'
t.is(updateReleaseDraft(pr, draft), expect)
t.is(prHelper.updateReleaseDraft(pr, draft), expect)
})

test.todo(
Expand Down
Loading