Skip to content

Commit 94d3f25

Browse files
committed
Keep PAT locally only, dont send it to server
1 parent ea8921b commit 94d3f25

File tree

8 files changed

+183
-80
lines changed

8 files changed

+183
-80
lines changed

packages/components/src/components/context/LoginHelpersContext.tsx

Lines changed: 121 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import _ from 'lodash'
1111
import qs from 'qs'
1212
import url from 'url'
1313
import { useDispatch } from 'react-redux'
14-
import { Alert } from 'react-native'
1514

1615
import { constants, tryParseOAuthParams } from '@devhub/core'
1716

@@ -40,9 +39,7 @@ export interface LoginHelpersProviderState {
4039
fullAccessRef: React.MutableRefObject<boolean>
4140
isExecutingOAuth: boolean
4241
isLoggingIn: boolean
43-
loginWithGitHub: ({
44-
fullAccess,
45-
}?: {
42+
loginWithGitHub: (params?: {
4643
fullAccess?: boolean | undefined
4744
}) => Promise<void>
4845
loginWithGitHubPersonalAccessToken: () => Promise<void>
@@ -79,8 +76,15 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
7976
>()
8077

8178
const dispatch = useDispatch()
79+
const githubBaseApiUrl = useReduxState(selectors.githubBaseApiUrlSelector)
8280
const existingAppToken = useReduxState(selectors.appTokenSelector)
8381
const isLoggingIn = useReduxState(selectors.isLoggingInSelector)
82+
const loggedGitHubUserId = useReduxState(
83+
(state) => selectors.currentGitHubUserSelector(state)?.id,
84+
)
85+
const loggedGitHubUsername = useReduxState(
86+
selectors.currentGitHubUsernameSelector,
87+
)
8488
const error = useReduxState(selectors.authErrorSelector)
8589
const hasGitHubToken = useReduxState(
8690
(state) => !!selectors.githubTokenSelector(state),
@@ -129,7 +133,9 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
129133
const token = await new Promise<string | undefined>((resolveToken) => {
130134
Dialog.show(
131135
'Personal Access Token',
132-
'To have private access, you need to include the "repo" scope. Paste your GitHub token here:',
136+
constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN
137+
? 'It will be stored safely on your local device and only be sent directly to GitHub.'
138+
: 'Enable private repository access.',
133139
[
134140
{
135141
text: 'Continue',
@@ -159,7 +165,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
159165
{
160166
type: 'plain-text',
161167
cancelable: true,
162-
placeholder: 'Personal Access Token',
168+
placeholder: 'Paste your Personal Access Token here',
163169
defaultValue: '',
164170
},
165171
)
@@ -179,80 +185,140 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
179185
const token = await promptForPersonalAcessToken()
180186
if (!token) throw new Error('Canceled')
181187

182-
setIsExecutingOAuth(true)
183-
const response = await axios.post(
184-
`${constants.API_BASE_URL}/github/personal/login`,
185-
{ token },
186-
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
187-
)
188-
setIsExecutingOAuth(false)
189-
190-
const appToken = response.data.appToken
191-
clearOAuthQueryParams()
192-
193-
if (!appToken) throw new Error('No app token')
194-
195-
dispatch(actions.loginRequest({ appToken }))
188+
if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) {
189+
setIsExecutingOAuth(true)
190+
setPATLoadingState('adding')
191+
const response = await axios.get(`${githubBaseApiUrl}/user`, {
192+
headers: {
193+
Authorization: `token ${token}`,
194+
},
195+
})
196+
setIsExecutingOAuth(false)
197+
setPATLoadingState(undefined)
198+
199+
if (!(response?.data?.id && response.data.login))
200+
throw new Error('Invalid response')
201+
202+
if (
203+
loggedGitHubUserId &&
204+
`${response.data.id}` !== `${loggedGitHubUserId}`
205+
) {
206+
const details =
207+
response.data.login !== loggedGitHubUsername
208+
? ` (${response.data.login} instead of ${loggedGitHubUsername})`
209+
: ` (ID ${response.data.id} instead of ${loggedGitHubUserId})`
210+
211+
throw new Error(
212+
`This Personal Access Token seems to be from a different user${details}.`,
213+
)
214+
}
215+
216+
const scope = `${response.headers['x-oauth-scopes'] || ''}`
217+
.replace(/\s+/g, '')
218+
.split(',')
219+
.filter(Boolean)
220+
221+
if (scope.length && !scope.includes('repo')) {
222+
throw new Error(
223+
'You didn\'t include the "repo" permission scope,' +
224+
' which is required to have access to private repositories.' +
225+
" Your token will be safe on your device, and will never be sent to DevHub's server.",
226+
)
227+
}
228+
229+
dispatch(
230+
actions.replacePersonalTokenDetails({
231+
tokenDetails: {
232+
login: response.data.login,
233+
token,
234+
tokenCreatedAt: new Date().toISOString(),
235+
scope,
236+
tokenType: undefined,
237+
},
238+
}),
239+
)
240+
} else {
241+
setIsExecutingOAuth(true)
242+
setPATLoadingState('adding')
243+
const response = await axios.post(
244+
`${constants.API_BASE_URL}/github/personal/login`,
245+
{ token },
246+
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
247+
)
248+
setIsExecutingOAuth(false)
249+
setPATLoadingState(undefined)
250+
251+
const appToken = response.data.appToken
252+
clearOAuthQueryParams()
253+
254+
if (!appToken) throw new Error('No app token')
255+
256+
dispatch(actions.loginRequest({ appToken }))
257+
}
196258
} catch (error) {
197259
setIsExecutingOAuth(false)
260+
setPATLoadingState(undefined)
261+
198262
if (error.message === 'Canceled' || error.message === 'Timeout') return
199263

200-
const description = 'OAuth execution failed'
264+
const description = 'Authentication failed'
201265
console.error(description, error)
202266

203267
bugsnag.notify(error, { description })
204268

205269
Dialog.show('Login failed', `${error || ''}`)
206270
}
207-
}, [existingAppToken])
271+
}, [existingAppToken, loggedGitHubUserId, loggedGitHubUsername])
208272

209273
const addPersonalAccessToken = useCallback(async () => {
210-
setPATLoadingState('adding')
211274
await loginWithGitHubPersonalAccessToken()
212-
setPATLoadingState(undefined)
213275
}, [loginWithGitHubPersonalAccessToken])
214276

215277
const removePersonalAccessToken = useCallback(async () => {
216-
try {
217-
setPATLoadingState('removing')
278+
if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) {
279+
dispatch(
280+
actions.replacePersonalTokenDetails({
281+
tokenDetails: undefined,
282+
}),
283+
)
284+
} else {
285+
try {
286+
setPATLoadingState('removing')
218287

219-
const response = await axios.post(
220-
constants.GRAPHQL_ENDPOINT,
221-
{
222-
query: `
288+
const response = await axios.post(
289+
constants.GRAPHQL_ENDPOINT,
290+
{
291+
query: `
223292
mutation {
224293
removeGitHubPersonalToken
225294
}`,
226-
},
227-
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
228-
)
229-
230-
const { data, errors } = await response.data
295+
},
296+
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
297+
)
231298

232-
if (errors && errors[0] && errors[0].message)
233-
throw new Error(errors[0].message)
299+
const { data, errors } = await response.data
234300

235-
if (!(data && data.removeGitHubPersonalToken)) {
236-
throw new Error('Not removed.')
237-
}
301+
if (errors?.[0]?.message) throw new Error(errors[0].message)
238302

239-
setPATLoadingState(undefined)
303+
if (!data?.removeGitHubPersonalToken) {
304+
throw new Error('Not removed.')
305+
}
240306

241-
// dispatch(
242-
// actions.replacePersonalTokenDetails({
243-
// tokenDetails: undefined,
244-
// }),
245-
// )
307+
setPATLoadingState(undefined)
246308

247-
// this is only necessary because we are not re-generating the appToken after removing the personal token,
248-
// which causes the personal token to being added back after a page refresh
249-
dispatch(actions.logout())
250-
} catch (error) {
251-
console.error(error)
252-
bugsnag.notify(error)
309+
// this is only necessary because we are not re-generating the appToken after removing the personal token,
310+
// which causes the personal token to being added back after a page refresh
311+
dispatch(actions.logout())
312+
} catch (error) {
313+
console.error(error)
314+
bugsnag.notify(error)
253315

254-
setPATLoadingState(undefined)
255-
Alert.alert(`Failed to remove personal token. \nError: ${error.message}`)
316+
setPATLoadingState(undefined)
317+
Dialog.show(
318+
'Failed to remove personal token',
319+
`Error: ${error?.message}`,
320+
)
321+
}
256322
}
257323
}, [existingAppToken])
258324

@@ -281,7 +347,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
281347
if (error.message === 'Canceled' || error.message === 'Timeout') return
282348
bugsnag.notify(error, { description })
283349

284-
Dialog.show('Login failed', `${error || ''}`)
350+
Dialog.show('Login failed', `Error: ${error?.message}`)
285351
}
286352
}, [])
287353

packages/components/src/components/widgets/PrivateAccessSettings.tsx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -91,26 +91,28 @@ export const PrivateAccessSettings = React.memo<PrivateAccessSettingsProps>(
9191
sharedStyles.paddingHorizontal,
9292
]}
9393
>
94-
{githubPersonalTokenDetails?.token ? (
95-
<ThemedText
96-
color="foregroundColorMuted65"
97-
style={sharedStyles.flex}
98-
>
99-
{new Array(githubPersonalTokenDetails.token.length)
100-
.fill('*')
101-
.join('')}
102-
</ThemedText>
103-
) : (
104-
<ThemedText
105-
color="foregroundColorMuted65"
106-
style={[sharedStyles.flex, { fontStyle: 'italic' }]}
107-
>
108-
Useful to get private repo support
109-
</ThemedText>
110-
)}
94+
{
95+
githubPersonalTokenDetails?.token ? (
96+
<ThemedText
97+
color="foregroundColorMuted65"
98+
style={sharedStyles.flex}
99+
>
100+
{new Array(githubPersonalTokenDetails.token.length)
101+
.fill('*')
102+
.join('')}
103+
</ThemedText>
104+
) : null
105+
// <>
106+
// <ThemedText
107+
// color="foregroundColorMuted65"
108+
// style={[sharedStyles.flex, { fontStyle: 'italic' }]}
109+
// >
110+
// Useful to get private repo support
111+
// </ThemedText>
112+
// <Spacer height={contentPadding} />
113+
// </>
114+
}
111115
</View>
112-
113-
<Spacer height={contentPadding} />
114116
</View>
115117
)
116118
},

packages/components/src/redux/reducers/github/api.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import immer from 'immer'
2+
import { REHYDRATE } from 'redux-persist'
23

34
import { GitHubAPIHeaders } from '@devhub/core'
5+
46
import { Reducer } from '../../types'
57

68
export interface State {
9+
baseApiUrl: string
10+
baseUrl: string
711
headers?: GitHubAPIHeaders
812
}
913

1014
const initialState: State = {
15+
baseApiUrl: 'https://api.github.com',
16+
baseUrl: 'https://github.com',
1117
headers: {
1218
pollInterval: undefined,
1319
rateLimitLimit: undefined,
@@ -21,10 +27,17 @@ export const githubAPIReducer: Reducer<State> = (
2127
action,
2228
) => {
2329
switch (action.type) {
30+
case REHYDRATE as any: {
31+
return {
32+
...initialState,
33+
...(action as any).payload?.github?.api,
34+
}
35+
}
36+
2437
case 'FETCH_SUBSCRIPTION_SUCCESS':
2538
case 'FETCH_SUBSCRIPTION_FAILURE':
2639
return immer(state, (draft) => {
27-
if (!action.payload.github) return
40+
if (!action.payload?.github?.headers) return
2841

2942
draft.headers = {
3043
...draft.headers,

packages/components/src/redux/reducers/github/auth.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import _ from 'lodash'
1+
import { constants, User } from '@devhub/core'
2+
import { REHYDRATE } from 'redux-persist'
23

3-
import { User } from '@devhub/core'
44
import { Reducer } from '../../types'
55

66
export interface State {
@@ -22,11 +22,20 @@ export const githubAuthReducer: Reducer<State> = (
2222
action,
2323
) => {
2424
switch (action.type) {
25+
case REHYDRATE as any: {
26+
return {
27+
...initialState,
28+
...(action as any).payload?.github?.auth,
29+
}
30+
}
31+
2532
case 'LOGIN_SUCCESS':
2633
return {
2734
app: action.payload.user.github.app,
2835
oauth: action.payload.user.github.oauth,
29-
personal: action.payload.user.github.personal,
36+
personal: constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN
37+
? state.personal
38+
: action.payload.user.github.personal,
3039
user: action.payload.user.github.user,
3140
}
3241

packages/components/src/redux/reducers/github/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import _ from 'lodash'
21
import { combineReducers } from 'redux'
32

43
import { githubAPIReducer } from './api'

packages/components/src/redux/reducers/github/installations.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import immer from 'immer'
2-
import _ from 'lodash'
2+
import { REHYDRATE } from 'redux-persist'
33

44
import { Installation, LoadState, normalizeInstallations } from '@devhub/core'
55
import { Reducer } from '../../types'
@@ -41,6 +41,13 @@ export const githubInstallationsReducer: Reducer<State> = (
4141
action,
4242
) => {
4343
switch (action.type) {
44+
case REHYDRATE as any: {
45+
return {
46+
...initialState,
47+
...(action as any).payload?.github?.installations,
48+
}
49+
}
50+
4451
case 'REFRESH_INSTALLATIONS_REQUEST':
4552
return immer(state, (draft) => {
4653
draft.lastFetchRequestAt = new Date().toISOString()

0 commit comments

Comments
 (0)