Skip to content

Commit 9d05159

Browse files
authored
feat: support fine-grained github tokens (#78)
1 parent dc2be95 commit 9d05159

3 files changed

Lines changed: 340 additions & 2 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,12 @@ Create a `release.config.json` file at the root of your project. Open the newly
8383

8484
### Generate GitHub Personal Access Token
8585

86-
Generate a [Personal Access Token](https://github.com/settings/tokens/new?scopes=repo,admin:repo_hook,admin:org_hook) for your GitHub user with the following permissions:
86+
Generate a [fine-grained Personal Access Token](https://github.com/settings/personal-access-tokens/new?contents=write&issues=write) for your GitHub user. Grant the token access to your repository and the following repository permissions:
87+
88+
- `Contents`: Read and write (create release commits, tags, and GitHub releases)
89+
- `Issues`: Read and write (comment on the issues referenced by the release)
90+
91+
Alternatively, you can use a classic [Personal Access Token](https://github.com/settings/tokens/new?scopes=repo,admin:repo_hook,admin:org_hook) with the following scopes:
8792

8893
- `repo`
8994
- `admin:repo_hook`
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { http, HttpResponse } from 'msw'
2+
import { api } from '#/test/env.js'
3+
import {
4+
getGitHubTokenType,
5+
validateAccessToken,
6+
validateFineGrainedAccessToken,
7+
GITHUB_NEW_FINE_GRAINED_TOKEN_URL,
8+
} from '#/src/utils/github/validate-access-token.js'
9+
10+
const repo = {
11+
owner: 'octocat',
12+
name: 'example',
13+
}
14+
15+
describe(getGitHubTokenType, () => {
16+
it('returns "fine-grained" for tokens with the "github_pat_" prefix', () => {
17+
expect(getGitHubTokenType('github_pat_11ABC')).toBe('fine-grained')
18+
})
19+
20+
it('returns "classic" for tokens with the "ghp_" prefix', () => {
21+
expect(getGitHubTokenType('ghp_ABC')).toBe('classic')
22+
})
23+
24+
it('returns "classic" for legacy 40-character tokens', () => {
25+
expect(getGitHubTokenType('a'.repeat(40))).toBe('classic')
26+
})
27+
})
28+
29+
describe(validateFineGrainedAccessToken, () => {
30+
it('resolves given a token with sufficient permissions', async () => {
31+
api.use(
32+
http.get('https://api.github.com/repos/octocat/example', () => {
33+
return HttpResponse.json({})
34+
}),
35+
http.post('https://api.github.com/repos/octocat/example/releases', () => {
36+
return new HttpResponse(null, { status: 422 })
37+
}),
38+
http.post(
39+
'https://api.github.com/repos/octocat/example/issues/0/comments',
40+
() => {
41+
return new HttpResponse(null, { status: 404 })
42+
},
43+
),
44+
)
45+
46+
await expect(
47+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
48+
).resolves.toBeUndefined()
49+
})
50+
51+
it('throws an error given a token that cannot access the repository', async () => {
52+
api.use(
53+
http.get('https://api.github.com/repos/octocat/example', () => {
54+
return new HttpResponse(null, { status: 404 })
55+
}),
56+
)
57+
58+
await expect(
59+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
60+
).rejects.toThrow(
61+
`Failed to verify GitHub token permissions: the provided fine-grained "GITHUB_TOKEN" cannot access the "octocat/example" repository. Please make sure that the repository access of the token includes that repository and try again.`,
62+
)
63+
})
64+
65+
it('throws an error given a generic error response from the API', async () => {
66+
api.use(
67+
http.get('https://api.github.com/repos/octocat/example', () => {
68+
return new HttpResponse(null, { status: 500 })
69+
}),
70+
)
71+
72+
await expect(
73+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
74+
).rejects.toThrow(
75+
`Failed to verify GitHub token permissions: GitHub API responded with 500 Internal Server Error. Please double-check your "GITHUB_TOKEN" environmental variable and try again.`,
76+
)
77+
})
78+
79+
it('throws an error given a token without the "contents: write" permission', async () => {
80+
api.use(
81+
http.get('https://api.github.com/repos/octocat/example', () => {
82+
return HttpResponse.json({})
83+
}),
84+
http.post('https://api.github.com/repos/octocat/example/releases', () => {
85+
return new HttpResponse(null, { status: 403 })
86+
}),
87+
http.post(
88+
'https://api.github.com/repos/octocat/example/issues/0/comments',
89+
() => {
90+
return new HttpResponse(null, { status: 404 })
91+
},
92+
),
93+
)
94+
95+
await expect(
96+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
97+
).rejects.toThrow(
98+
`Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing permissions "contents: write". Please generate a new GitHub fine-grained personal access token from this URL: ${GITHUB_NEW_FINE_GRAINED_TOKEN_URL}`,
99+
)
100+
})
101+
102+
it('throws an error given a token without the "issues: write" permission', async () => {
103+
api.use(
104+
http.get('https://api.github.com/repos/octocat/example', () => {
105+
return HttpResponse.json({})
106+
}),
107+
http.post('https://api.github.com/repos/octocat/example/releases', () => {
108+
return new HttpResponse(null, { status: 422 })
109+
}),
110+
http.post(
111+
'https://api.github.com/repos/octocat/example/issues/0/comments',
112+
() => {
113+
return new HttpResponse(null, { status: 403 })
114+
},
115+
),
116+
)
117+
118+
await expect(
119+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
120+
).rejects.toThrow(
121+
`Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing permissions "issues: write". Please generate a new GitHub fine-grained personal access token from this URL: ${GITHUB_NEW_FINE_GRAINED_TOKEN_URL}`,
122+
)
123+
})
124+
125+
it('throws an error given a token with missing multiple permissions', async () => {
126+
api.use(
127+
http.get('https://api.github.com/repos/octocat/example', () => {
128+
return HttpResponse.json({})
129+
}),
130+
http.post('https://api.github.com/repos/octocat/example/releases', () => {
131+
return new HttpResponse(null, { status: 403 })
132+
}),
133+
http.post(
134+
'https://api.github.com/repos/octocat/example/issues/0/comments',
135+
() => {
136+
return new HttpResponse(null, { status: 403 })
137+
},
138+
),
139+
)
140+
141+
await expect(
142+
validateFineGrainedAccessToken('github_pat_TOKEN', repo),
143+
).rejects.toThrow(
144+
`Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing permissions "contents: write", "issues: write". Please generate a new GitHub fine-grained personal access token from this URL: ${GITHUB_NEW_FINE_GRAINED_TOKEN_URL}`,
145+
)
146+
})
147+
})
148+
149+
describe(validateAccessToken, () => {
150+
it('validates fine-grained tokens against the repository from Git info', async () => {
151+
api.use(
152+
http.get('https://api.github.com/repos/:owner/:name', () => {
153+
return HttpResponse.json({})
154+
}),
155+
http.post('https://api.github.com/repos/:owner/:name/releases', () => {
156+
return new HttpResponse(null, { status: 422 })
157+
}),
158+
http.post(
159+
'https://api.github.com/repos/:owner/:name/issues/0/comments',
160+
() => {
161+
return new HttpResponse(null, { status: 404 })
162+
},
163+
),
164+
)
165+
166+
await expect(
167+
validateAccessToken('github_pat_TOKEN'),
168+
).resolves.toBeUndefined()
169+
})
170+
})

src/utils/github/validate-access-token.ts

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { invariant } from 'outvariant'
2+
import { getInfo, type GitInfo } from '#/src/utils/git/get-info.js'
23

3-
export const requiredGitHubTokenScopes: string[] = [
4+
export type GitHubTokenType = 'classic' | 'fine-grained'
5+
6+
export type GitHubRepo = Pick<GitInfo, 'owner' | 'name'>
7+
8+
const FINE_GRAINED_TOKEN_PREFIX = 'github_pat_'
9+
10+
export function getGitHubTokenType(accessToken: string): GitHubTokenType {
11+
if (accessToken.startsWith(FINE_GRAINED_TOKEN_PREFIX)) {
12+
return 'fine-grained'
13+
}
14+
15+
return 'classic'
16+
}
17+
18+
export const requiredGitHubTokenScopes: Array<string> = [
419
'repo',
520
'admin:repo_hook',
621
'admin:org_hook',
@@ -10,11 +25,93 @@ export const GITHUB_NEW_TOKEN_URL = `https://github.com/settings/tokens/new?scop
1025
',',
1126
)}`
1227

28+
export interface FineGrainedTokenPermission {
29+
permission: string
30+
access: 'read' | 'write'
31+
/**
32+
* Check whether the given access token is granted this permission.
33+
*/
34+
probe: (accessToken: string, repo: GitHubRepo) => Promise<boolean>
35+
}
36+
37+
export const requiredFineGrainedTokenPermissions: Array<FineGrainedTokenPermission> =
38+
[
39+
{
40+
permission: 'contents',
41+
access: 'write',
42+
async probe(accessToken, repo) {
43+
/**
44+
* Attempt to create a release without any payload.
45+
* GitHub checks the token permissions before validating
46+
* the payload: a validation error (422) means the token can
47+
* create releases while 403/404 mean the permission is missing.
48+
*/
49+
const response = await fetch(
50+
`https://api.github.com/repos/${repo.owner}/${repo.name}/releases`,
51+
{
52+
method: 'POST',
53+
headers: {
54+
Authorization: `Bearer ${accessToken}`,
55+
},
56+
body: JSON.stringify({}),
57+
},
58+
)
59+
60+
return response.status === 422
61+
},
62+
},
63+
{
64+
permission: 'issues',
65+
access: 'write',
66+
async probe(accessToken, repo) {
67+
/**
68+
* Attempt to comment on an issue that can never exist (#0).
69+
* A 404/422 response means the token has passed the permission
70+
* check while 403 means the permission is missing.
71+
*/
72+
const response = await fetch(
73+
`https://api.github.com/repos/${repo.owner}/${repo.name}/issues/0/comments`,
74+
{
75+
method: 'POST',
76+
headers: {
77+
Authorization: `Bearer ${accessToken}`,
78+
},
79+
body: JSON.stringify({}),
80+
},
81+
)
82+
83+
return response.status === 404 || response.status === 422
84+
},
85+
},
86+
]
87+
88+
export const GITHUB_NEW_FINE_GRAINED_TOKEN_URL = `https://github.com/settings/personal-access-tokens/new?${requiredFineGrainedTokenPermissions
89+
.map((requiredPermission) => {
90+
return `${requiredPermission.permission}=${requiredPermission.access}`
91+
})
92+
.join('&')}`
93+
1394
/**
1495
* Check whether the given GitHub access token has sufficient permissions
1596
* for this library to create and publish a new release.
1697
*/
1798
export async function validateAccessToken(accessToken: string): Promise<void> {
99+
if (getGitHubTokenType(accessToken) === 'fine-grained') {
100+
const repo = await getInfo()
101+
await validateFineGrainedAccessToken(accessToken, repo)
102+
return
103+
}
104+
105+
await validateClassicAccessToken(accessToken)
106+
}
107+
108+
/**
109+
* Check whether the given classic GitHub access token (OAuth)
110+
* has sufficient permission scopes.
111+
*/
112+
export async function validateClassicAccessToken(
113+
accessToken: string,
114+
): Promise<void> {
18115
const response = await fetch('https://api.github.com', {
19116
headers: {
20117
Authorization: `Bearer ${accessToken}`,
@@ -52,3 +149,69 @@ export async function validateAccessToken(accessToken: string): Promise<void> {
52149
)
53150
}
54151
}
152+
153+
/**
154+
* Check whether the given fine-grained GitHub access token has
155+
* sufficient permissions for the given repository.
156+
*/
157+
export async function validateFineGrainedAccessToken(
158+
accessToken: string,
159+
repo: GitHubRepo,
160+
): Promise<void> {
161+
const repoResponse = await fetch(
162+
`https://api.github.com/repos/${repo.owner}/${repo.name}`,
163+
{
164+
headers: {
165+
Authorization: `Bearer ${accessToken}`,
166+
},
167+
},
168+
)
169+
170+
invariant(
171+
repoResponse.status !== 404,
172+
'Failed to verify GitHub token permissions: the provided fine-grained "GITHUB_TOKEN" cannot access the "%s/%s" repository. Please make sure that the repository access of the token includes that repository and try again.',
173+
repo.owner,
174+
repo.name,
175+
)
176+
177+
// Handle generic error responses.
178+
invariant(
179+
repoResponse.ok,
180+
'Failed to verify GitHub token permissions: GitHub API responded with %d %s. Please double-check your "GITHUB_TOKEN" environmental variable and try again.',
181+
repoResponse.status,
182+
repoResponse.statusText,
183+
)
184+
185+
/**
186+
* @note GitHub provides no API to list the permissions granted
187+
* to a fine-grained token so probe the endpoints behind the
188+
* required permissions instead.
189+
*/
190+
const probedPermissions = await Promise.all(
191+
requiredFineGrainedTokenPermissions.map(async (requiredPermission) => {
192+
const isGranted = await requiredPermission.probe(accessToken, repo)
193+
194+
return {
195+
requiredPermission,
196+
isGranted,
197+
}
198+
}),
199+
)
200+
201+
const missingPermissions = probedPermissions
202+
.filter((probedPermission) => {
203+
return !probedPermission.isGranted
204+
})
205+
.map((probedPermission) => {
206+
return `${probedPermission.requiredPermission.permission}: ${probedPermission.requiredPermission.access}`
207+
})
208+
209+
if (missingPermissions.length > 0) {
210+
invariant(
211+
false,
212+
'Provided "GITHUB_TOKEN" environment variable has insufficient permissions: missing permissions "%s". Please generate a new GitHub fine-grained personal access token from this URL: %s',
213+
missingPermissions.join(`", "`),
214+
GITHUB_NEW_FINE_GRAINED_TOKEN_URL,
215+
)
216+
}
217+
}

0 commit comments

Comments
 (0)