Skip to content

Commit f79a22c

Browse files
committed
Add support functions to authenticate as GitHub App
In particular, this adds a function to obtain the installation access token for a given repository. This function will be used e.g. to trigger GitHub workflow runs. These functions are slightly edited versions of functions that have been in use in https://github.com/git-for-windows/gfw-helper-github-app for quite a while. But the test is new. Signed-off-by: Johannes Schindelin <[email protected]>
1 parent 1142a2f commit f79a22c

File tree

7 files changed

+207
-1
lines changed

7 files changed

+207
-1
lines changed

GitGitGadget/gently.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module.exports = (fn, fallback) => {
2+
try {
3+
return fn()
4+
} catch (e) {
5+
return fallback
6+
}
7+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const getInstallationAccessToken = async (context, installation_id) => {
2+
const { gitHubAPIRequestAsApp } = require('./github-api-request-as-app')
3+
const answer = await gitHubAPIRequestAsApp(
4+
context,
5+
'POST',
6+
`/app/installations/${installation_id}/access_tokens`)
7+
if (answer.error) throw answer.error
8+
if (answer.token) return answer.token
9+
throw new Error(`Unhandled response:\n${JSON.stringify(answer, null, 2)}`)
10+
}
11+
12+
module.exports = {
13+
getInstallationAccessToken
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const getInstallationIdForRepo = async (context, owner, repo) => {
2+
const { gitHubAPIRequestAsApp } = require('./github-api-request-as-app')
3+
const answer = await gitHubAPIRequestAsApp(
4+
context,
5+
'GET',
6+
`/repos/${owner}/${repo}/installation`
7+
)
8+
if (answer.error) throw answer.error
9+
return answer.id
10+
}
11+
12+
module.exports = {
13+
getInstallationIdForRepo
14+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const gitHubAPIRequestAsApp = async (context, requestMethod, requestPath, body) => {
2+
const header = {
3+
"alg": "RS256",
4+
"typ": "JWT"
5+
}
6+
7+
const now = Math.floor(new Date().getTime() / 1000)
8+
9+
const payload = {
10+
// issued at time, 60 seconds in the past to allow for clock drift
11+
iat: now - 60,
12+
// JWT expiration time (10 minute maximum)
13+
exp: now + (10 * 60),
14+
// GitHub App's identifier
15+
iss: process.env['GITHUB_APP_ID']
16+
}
17+
18+
const toBase64 = (obj) => Buffer.from(JSON.stringify(obj), "utf-8").toString("base64url")
19+
const headerAndPayload = `${toBase64(header)}.${toBase64(payload)}`
20+
21+
const privateKey = process.env['GITHUB_APP_PRIVATE_KEY'].replaceAll('\\n', '\n')
22+
23+
const crypto = require('crypto')
24+
const signer = crypto.createSign("RSA-SHA256")
25+
signer.update(headerAndPayload)
26+
const signature = signer.sign({
27+
key: privateKey
28+
}, "base64url")
29+
30+
const token = `${headerAndPayload}.${signature}`
31+
32+
const { httpsRequest } = require('./https-request')
33+
return await httpsRequest(
34+
context,
35+
null,
36+
requestMethod,
37+
requestPath,
38+
body,
39+
{
40+
Authorization: `Bearer ${token}`,
41+
}
42+
)
43+
}
44+
45+
module.exports = {
46+
gitHubAPIRequestAsApp
47+
}

GitGitGadget/https-request.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
const gently = require('./gently')
2+
3+
const httpsRequest = async (context, hostname, method, requestPath, body, headers) => {
4+
headers = {
5+
'User-Agent': 'GitForWindowsHelper/0.0',
6+
Accept: 'application/json',
7+
...headers || {}
8+
}
9+
if (body) {
10+
if (typeof body === 'object') body = JSON.stringify(body)
11+
headers['Content-Type'] = 'application/json'
12+
headers['Content-Length'] = body.length
13+
}
14+
const options = {
15+
port: 443,
16+
hostname: hostname || 'api.github.com',
17+
method: method || 'GET',
18+
path: requestPath,
19+
headers
20+
}
21+
return new Promise((resolve, reject) => {
22+
try {
23+
const https = require('https')
24+
const req = https.request(options, res => {
25+
res.on('error', e => reject(e))
26+
27+
if (res.statusCode === 204) resolve({
28+
statusCode: res.statusCode,
29+
statusMessage: res.statusMessage,
30+
headers: res.headers
31+
})
32+
33+
const chunks = []
34+
res.on('data', data => chunks.push(data))
35+
res.on('end', () => {
36+
const json = Buffer.concat(chunks).toString('utf-8')
37+
if (res.statusCode > 299) {
38+
reject({
39+
statusCode: res.statusCode,
40+
statusMessage: res.statusMessage,
41+
requestMethod: options.method,
42+
requestPath: options.path,
43+
body: json,
44+
json: gently(() => JSON.parse(json))
45+
})
46+
return
47+
}
48+
try {
49+
resolve(JSON.parse(json))
50+
} catch (e) {
51+
reject(`Invalid JSON: ${json}`)
52+
}
53+
})
54+
})
55+
req.on('error', err => reject(err))
56+
if (body) req.write(body)
57+
req.end()
58+
} catch (e) {
59+
reject(e)
60+
}
61+
})
62+
}
63+
64+
const doesURLReturn404 = async url => {
65+
const match = url.match(/^https:\/\/([^/]+?)(:\d+)?(\/.*)?$/)
66+
if (!match) throw new Error(`Could not parse URL ${url}`)
67+
68+
const https = require('https')
69+
const options = {
70+
method: 'HEAD',
71+
host: match[1],
72+
port: Number.parseInt(match[2] || '443'),
73+
path: match[3] || '/'
74+
}
75+
return new Promise((resolve, reject) => {
76+
https.request(options, res => {
77+
if (res.error) reject(res.error)
78+
else if (res.statusCode === 404) resolve(true)
79+
else if (res.statusCode === 200) resolve(false)
80+
else reject(`Unexpected statusCode: ${res.statusCode}`)
81+
}).end()
82+
})
83+
}
84+
85+
module.exports = {
86+
httpsRequest,
87+
doesURLReturn404
88+
}

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ Instead of pushing the code to Azure all the time, waiting until it is deployed,
2121

2222
To this end, [install the Azure Functions Core Tools (for performance, use Linux)](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Clinux%2Ccsharp%2Cportal%2Cbash#install-the-azure-functions-core-tools, e.g. via [WSL](https://learn.microsoft.com/en-us/windows/wsl/)).
2323

24-
Then, configure [the `GITHUB_WEBHOOK_SECRET` variable](#some-environment-variables) locally, via [a `local.settings.json` file](https://learn.microsoft.com/en-us/azure/azure-functions/functions-develop-local#local-settings-file). The contents would look like this:
24+
Then, configure [the `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY` and `GITHUB_WEBHOOK_SECRET` variables](#some-environment-variables) locally, via [a `local.settings.json` file](https://learn.microsoft.com/en-us/azure/azure-functions/functions-develop-local#local-settings-file). The contents would look like this:
2525

2626
```json
2727
{
2828
"IsEncrypted": false,
2929
"Values": {
3030
"FUNCTIONS_WORKER_RUNTIME": "node",
3131
"AzureWebJobsStorage": "<storage-key>",
32+
"GITHUB_APP_ID": "<app-id>",
33+
"GITHUB_APP_PRIVATE_KEY": "<private-key>",
3234
"GITHUB_WEBHOOK_SECRET": "<webhook-secret>"
3335
},
3436
"Host": {
@@ -61,6 +63,8 @@ A few environment variables will have to be configured for use with the Azure Fu
6163

6264
Concretely, the environment variables `GITHUB_WEBHOOK_SECRET` and `GITGITGADGET_TRIGGER_TOKEN` (a Personal Access Token to trigger the Azure Pipelines) need to be set. For the first, a generated random string was used. The second one was [created](https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows#create-a-pat) scoped to the Azure DevOps project `gitgitgadget` with the Build (read & execute) permissions.
6365

66+
Also, the `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` variables are needed in order to trigger GitHub workflow runs. These were obtained as part of registering the GitHub App.
67+
6468
### The repository
6569

6670
On https://github.com/, the `+` link on the top was pressed, and an empty, private repository was registered. Nothing was pushed to it yet.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const mockHTTPSRequest = jest.fn(async (context, hostname, method, requestPath, body, headers) => {
2+
// We're not validating the authorization, just validating that there is one
3+
expect(headers.Authorization).toMatch(/Bearer [-.0-9A-Za-z_]{40,}/)
4+
5+
if (requestPath === '/repos/hello/world/installation') return { id: 17 }
6+
if (requestPath === '/app/installations/17/access_tokens') return { token: 'i-can-haz-access-token' }
7+
throw new Error(`Unexpected requestPath: '${requestPath}'`)
8+
})
9+
jest.mock('../GitGitGadget/https-request', () => { return { httpsRequest: mockHTTPSRequest } })
10+
const { getInstallationIdForRepo } = require('../GitGitGadget/get-installation-id-for-repo')
11+
const { getInstallationAccessToken } = require('../GitGitGadget/get-installation-access-token')
12+
13+
const { generateKeyPairSync } = require('crypto')
14+
15+
const { privateKey } = generateKeyPairSync('rsa', {
16+
modulusLength: 2048,
17+
publicKeyEncoding: {
18+
type: 'spki',
19+
format: 'pem'
20+
},
21+
privateKeyEncoding: {
22+
type: 'pkcs8',
23+
format: 'pem',
24+
}
25+
})
26+
process.env['GITHUB_APP_PRIVATE_KEY'] = privateKey
27+
28+
test('get an installation access token', async () => {
29+
const context = {}
30+
const installationID = await getInstallationIdForRepo(context, 'hello', 'world')
31+
expect(await getInstallationAccessToken(context, installationID)).toEqual('i-can-haz-access-token')
32+
})

0 commit comments

Comments
 (0)