Skip to content

Commit e1941d8

Browse files
authored
fix: verify PAT gist scope (#1722)
1 parent 4fa98a6 commit e1941d8

File tree

2 files changed

+239
-22
lines changed

2 files changed

+239
-22
lines changed

src/renderer/components/dialog-token.tsx

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface TokenDialogState {
1414
tokenInput: string;
1515
verifying: boolean;
1616
error: boolean;
17+
errorMessage?: string;
1718
}
1819

1920
const TOKEN_SCOPES = ['gist'].join();
@@ -37,6 +38,7 @@ export const TokenDialog = observer(
3738
this.state = {
3839
verifying: false,
3940
error: false,
41+
errorMessage: undefined,
4042
tokenInput: '',
4143
};
4244

@@ -49,28 +51,79 @@ export const TokenDialog = observer(
4951
}
5052

5153
/**
52-
* Handles the submission of a token
54+
* Validates a GitHub token and checks for required scopes.
55+
*/
56+
private async validateGitHubToken(token: string): Promise<{
57+
isValid: boolean;
58+
scopes: string[];
59+
hasGistScope: boolean;
60+
user?: any;
61+
error?: string;
62+
}> {
63+
try {
64+
const octokit = await getOctokit({ gitHubToken: token } as AppState);
65+
const response = await octokit.users.getAuthenticated();
66+
67+
const scopes = response.headers['x-oauth-scopes']?.split(', ') || [];
68+
const hasGistScope = scopes.includes('gist');
69+
70+
return {
71+
isValid: true,
72+
scopes,
73+
hasGistScope,
74+
user: response.data,
75+
};
76+
} catch (error) {
77+
return {
78+
isValid: false,
79+
scopes: [],
80+
hasGistScope: false,
81+
error: error.message,
82+
};
83+
}
84+
}
85+
86+
/**
87+
* Handles the submission of a token and verifies
88+
* that it has the correct scopes.
5389
*/
5490
public async onSubmitToken(): Promise<void> {
5591
if (!this.state.tokenInput) return;
56-
this.setState({ verifying: true });
57-
this.props.appState.gitHubToken = this.state.tokenInput;
58-
59-
const octo = await getOctokit(this.props.appState);
92+
this.setState({ verifying: true, error: false, errorMessage: undefined });
93+
94+
const validation = await this.validateGitHubToken(this.state.tokenInput);
95+
96+
if (!validation.isValid) {
97+
console.warn(`Authenticating against GitHub failed`, validation.error);
98+
this.setState({
99+
verifying: false,
100+
error: true,
101+
errorMessage:
102+
'Invalid GitHub token. Please check your token and try again.',
103+
});
104+
this.props.appState.gitHubToken = null;
105+
return;
106+
}
60107

61-
try {
62-
const { data } = await octo.users.getAuthenticated();
63-
this.props.appState.gitHubAvatarUrl = data.avatar_url;
64-
this.props.appState.gitHubLogin = data.login;
65-
this.props.appState.gitHubName = data.name;
66-
this.setState({ verifying: false, error: false });
67-
} catch (error) {
68-
console.warn(`Authenticating against GitHub failed`, error);
69-
this.setState({ verifying: false, error: true });
108+
if (!validation.hasGistScope) {
109+
console.warn(`Token missing required gist scope`);
110+
this.setState({
111+
verifying: false,
112+
error: true,
113+
errorMessage:
114+
'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
115+
});
70116
this.props.appState.gitHubToken = null;
71117
return;
72118
}
73119

120+
// Token is valid and has required scopes.
121+
this.props.appState.gitHubToken = this.state.tokenInput;
122+
this.props.appState.gitHubAvatarUrl = validation.user.avatar_url;
123+
this.props.appState.gitHubLogin = validation.user.login;
124+
this.props.appState.gitHubName = validation.user.name;
125+
126+
this.setState({ verifying: false, error: false });
74127
this.props.appState.isTokenDialogShowing = false;
75128
}
76129

@@ -89,6 +142,7 @@ export const TokenDialog = observer(
89142
this.setState({
90143
verifying: false,
91144
error: false,
145+
errorMessage: undefined,
92146
tokenInput: '',
93147
});
94148
}
@@ -141,11 +195,12 @@ export const TokenDialog = observer(
141195
}
142196

143197
get invalidWarning() {
198+
const message =
199+
this.state.errorMessage ||
200+
'Please provide a valid GitHub Personal Access Token';
144201
return (
145202
<>
146-
<Callout intent={Intent.DANGER}>
147-
Please provide a valid GitHub Personal Access Token
148-
</Callout>
203+
<Callout intent={Intent.DANGER}>{message}</Callout>
149204
<br />
150205
</>
151206
);

tests/renderer/components/dialog-token-spec.tsx

Lines changed: 167 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ describe('TokenDialog component', () => {
6565
const wrapper = shallow(<TokenDialog appState={store} />);
6666
const instance: any = wrapper.instance();
6767

68-
wrapper.setState({ verifying: true, tokenInput: 'hello' });
68+
wrapper.setState({
69+
verifying: true,
70+
tokenInput: 'hello',
71+
errorMessage: 'test error',
72+
});
6973
instance.reset();
7074

7175
expect(wrapper.state()).toEqual({
7276
verifying: false,
7377
error: false,
78+
errorMessage: undefined,
7479
tokenInput: '',
7580
});
7681
});
@@ -79,12 +84,17 @@ describe('TokenDialog component', () => {
7984
const wrapper = shallow(<TokenDialog appState={store} />);
8085
const instance: any = wrapper.instance();
8186

82-
wrapper.setState({ verifying: true, tokenInput: 'hello' });
87+
wrapper.setState({
88+
verifying: true,
89+
tokenInput: 'hello',
90+
errorMessage: 'test error',
91+
});
8392
instance.onClose();
8493

8594
expect(wrapper.state()).toEqual({
8695
verifying: false,
8796
error: false,
97+
errorMessage: undefined,
8898
tokenInput: '',
8999
});
90100
});
@@ -121,7 +131,12 @@ describe('TokenDialog component', () => {
121131
mockOctokit = {
122132
authenticate: vi.fn(),
123133
users: {
124-
getAuthenticated: vi.fn().mockResolvedValue({ data: mockUser }),
134+
getAuthenticated: vi.fn().mockResolvedValue({
135+
data: mockUser,
136+
headers: {
137+
'x-oauth-scopes': 'gist, repo',
138+
},
139+
}),
125140
},
126141
} as unknown as Octokit;
127142

@@ -149,11 +164,55 @@ describe('TokenDialog component', () => {
149164
expect(store.gitHubLogin).toBe(mockUser.login);
150165
expect(store.gitHubName).toBe(mockUser.name);
151166
expect(store.gitHubAvatarUrl).toBe(mockUser.avatar_url);
167+
expect(wrapper.state('error')).toBe(false);
168+
expect(store.isTokenDialogShowing).toBe(false);
169+
});
170+
171+
it('handles an invalid token error', async () => {
172+
vi.mocked(mockOctokit.users.getAuthenticated).mockRejectedValue(
173+
new Error('Bad credentials'),
174+
);
175+
176+
const wrapper = shallow(<TokenDialog appState={store} />);
177+
wrapper.setState({ tokenInput: mockValidToken });
178+
const instance: any = wrapper.instance();
179+
180+
await instance.onSubmitToken();
181+
182+
expect(wrapper.state('error')).toBe(true);
183+
expect(wrapper.state('errorMessage')).toBe(
184+
'Invalid GitHub token. Please check your token and try again.',
185+
);
186+
expect(store.gitHubToken).toEqual(null);
187+
});
188+
189+
it('handles missing gist scope', async () => {
190+
vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({
191+
data: mockUser,
192+
headers: {
193+
'x-oauth-scopes': 'repo, user', // Missing 'gist' scope
194+
},
195+
} as any);
196+
197+
const wrapper = shallow(<TokenDialog appState={store} />);
198+
wrapper.setState({ tokenInput: mockValidToken });
199+
const instance: any = wrapper.instance();
200+
201+
await instance.onSubmitToken();
202+
203+
expect(wrapper.state('error')).toBe(true);
204+
expect(wrapper.state('errorMessage')).toBe(
205+
'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
206+
);
207+
expect(store.gitHubToken).toEqual(null);
152208
});
153209

154-
it('handles an error', async () => {
210+
it('handles empty scopes header', async () => {
155211
vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({
156-
data: null,
212+
data: mockUser,
213+
headers: {
214+
// No x-oauth-scopes header
215+
},
157216
} as any);
158217

159218
const wrapper = shallow(<TokenDialog appState={store} />);
@@ -163,7 +222,110 @@ describe('TokenDialog component', () => {
163222
await instance.onSubmitToken();
164223

165224
expect(wrapper.state('error')).toBe(true);
225+
expect(wrapper.state('errorMessage')).toBe(
226+
'Token is missing the "gist" scope. Please generate a new token with gist permissions.',
227+
);
166228
expect(store.gitHubToken).toEqual(null);
167229
});
168230
});
231+
232+
describe('validateGitHubToken()', () => {
233+
let mockOctokit: Octokit;
234+
const mockUser = {
235+
avatar_url: 'https://avatars.fake/hi',
236+
login: 'test-login',
237+
name: 'Test User',
238+
} as const;
239+
240+
beforeEach(() => {
241+
mockOctokit = {
242+
users: {
243+
getAuthenticated: vi.fn(),
244+
},
245+
} as unknown as Octokit;
246+
247+
vi.mocked(getOctokit).mockResolvedValue(mockOctokit);
248+
});
249+
250+
it('validates a token with gist scope', async () => {
251+
vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({
252+
data: mockUser,
253+
headers: {
254+
'x-oauth-scopes': 'gist, repo, user',
255+
},
256+
} as any);
257+
258+
const wrapper = shallow(<TokenDialog appState={store} />);
259+
const instance: any = wrapper.instance();
260+
261+
const result = await instance.validateGitHubToken('valid-token');
262+
263+
expect(result).toEqual({
264+
isValid: true,
265+
scopes: ['gist', 'repo', 'user'],
266+
hasGistScope: true,
267+
user: mockUser,
268+
});
269+
});
270+
271+
it('validates a token without gist scope', async () => {
272+
vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({
273+
data: mockUser,
274+
headers: {
275+
'x-oauth-scopes': 'repo, user',
276+
},
277+
} as any);
278+
279+
const wrapper = shallow(<TokenDialog appState={store} />);
280+
const instance: any = wrapper.instance();
281+
282+
const result = await instance.validateGitHubToken('token-without-gist');
283+
284+
expect(result).toEqual({
285+
isValid: true,
286+
scopes: ['repo', 'user'],
287+
hasGistScope: false,
288+
user: mockUser,
289+
});
290+
});
291+
292+
it('handles invalid token', async () => {
293+
vi.mocked(mockOctokit.users.getAuthenticated).mockRejectedValue(
294+
new Error('Bad credentials'),
295+
);
296+
297+
const wrapper = shallow(<TokenDialog appState={store} />);
298+
const instance: any = wrapper.instance();
299+
300+
const result = await instance.validateGitHubToken('invalid-token');
301+
302+
expect(result).toEqual({
303+
isValid: false,
304+
scopes: [],
305+
hasGistScope: false,
306+
error: 'Bad credentials',
307+
});
308+
});
309+
310+
it('handles missing scopes header', async () => {
311+
vi.mocked(mockOctokit.users.getAuthenticated).mockResolvedValue({
312+
data: mockUser,
313+
headers: {},
314+
} as any);
315+
316+
const wrapper = shallow(<TokenDialog appState={store} />);
317+
const instance: any = wrapper.instance();
318+
319+
const result = await instance.validateGitHubToken(
320+
'token-no-scopes-header',
321+
);
322+
323+
expect(result).toEqual({
324+
isValid: true,
325+
scopes: [],
326+
hasGistScope: false,
327+
user: mockUser,
328+
});
329+
});
330+
});
169331
});

0 commit comments

Comments
 (0)