Skip to content

Commit

Permalink
Keep PAT locally only, dont send it to server
Browse files Browse the repository at this point in the history
  • Loading branch information
brunolemos committed Dec 8, 2020
1 parent ea8921b commit 94d3f25
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 80 deletions.
176 changes: 121 additions & 55 deletions packages/components/src/components/context/LoginHelpersContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import _ from 'lodash'
import qs from 'qs'
import url from 'url'
import { useDispatch } from 'react-redux'
import { Alert } from 'react-native'

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

Expand Down Expand Up @@ -40,9 +39,7 @@ export interface LoginHelpersProviderState {
fullAccessRef: React.MutableRefObject<boolean>
isExecutingOAuth: boolean
isLoggingIn: boolean
loginWithGitHub: ({
fullAccess,
}?: {
loginWithGitHub: (params?: {
fullAccess?: boolean | undefined
}) => Promise<void>
loginWithGitHubPersonalAccessToken: () => Promise<void>
Expand Down Expand Up @@ -79,8 +76,15 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
>()

const dispatch = useDispatch()
const githubBaseApiUrl = useReduxState(selectors.githubBaseApiUrlSelector)
const existingAppToken = useReduxState(selectors.appTokenSelector)
const isLoggingIn = useReduxState(selectors.isLoggingInSelector)
const loggedGitHubUserId = useReduxState(
(state) => selectors.currentGitHubUserSelector(state)?.id,
)
const loggedGitHubUsername = useReduxState(
selectors.currentGitHubUsernameSelector,
)
const error = useReduxState(selectors.authErrorSelector)
const hasGitHubToken = useReduxState(
(state) => !!selectors.githubTokenSelector(state),
Expand Down Expand Up @@ -129,7 +133,9 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
const token = await new Promise<string | undefined>((resolveToken) => {
Dialog.show(
'Personal Access Token',
'To have private access, you need to include the "repo" scope. Paste your GitHub token here:',
constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN
? 'It will be stored safely on your local device and only be sent directly to GitHub.'
: 'Enable private repository access.',
[
{
text: 'Continue',
Expand Down Expand Up @@ -159,7 +165,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
{
type: 'plain-text',
cancelable: true,
placeholder: 'Personal Access Token',
placeholder: 'Paste your Personal Access Token here',
defaultValue: '',
},
)
Expand All @@ -179,80 +185,140 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
const token = await promptForPersonalAcessToken()
if (!token) throw new Error('Canceled')

setIsExecutingOAuth(true)
const response = await axios.post(
`${constants.API_BASE_URL}/github/personal/login`,
{ token },
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
)
setIsExecutingOAuth(false)

const appToken = response.data.appToken
clearOAuthQueryParams()

if (!appToken) throw new Error('No app token')

dispatch(actions.loginRequest({ appToken }))
if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) {
setIsExecutingOAuth(true)
setPATLoadingState('adding')
const response = await axios.get(`${githubBaseApiUrl}/user`, {
headers: {
Authorization: `token ${token}`,
},
})
setIsExecutingOAuth(false)
setPATLoadingState(undefined)

if (!(response?.data?.id && response.data.login))
throw new Error('Invalid response')

if (
loggedGitHubUserId &&
`${response.data.id}` !== `${loggedGitHubUserId}`
) {
const details =
response.data.login !== loggedGitHubUsername
? ` (${response.data.login} instead of ${loggedGitHubUsername})`
: ` (ID ${response.data.id} instead of ${loggedGitHubUserId})`

throw new Error(
`This Personal Access Token seems to be from a different user${details}.`,
)
}

const scope = `${response.headers['x-oauth-scopes'] || ''}`
.replace(/\s+/g, '')
.split(',')
.filter(Boolean)

if (scope.length && !scope.includes('repo')) {
throw new Error(
'You didn\'t include the "repo" permission scope,' +
' which is required to have access to private repositories.' +
" Your token will be safe on your device, and will never be sent to DevHub's server.",
)
}

dispatch(
actions.replacePersonalTokenDetails({
tokenDetails: {
login: response.data.login,
token,
tokenCreatedAt: new Date().toISOString(),
scope,
tokenType: undefined,
},
}),
)
} else {
setIsExecutingOAuth(true)
setPATLoadingState('adding')
const response = await axios.post(
`${constants.API_BASE_URL}/github/personal/login`,
{ token },
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
)
setIsExecutingOAuth(false)
setPATLoadingState(undefined)

const appToken = response.data.appToken
clearOAuthQueryParams()

if (!appToken) throw new Error('No app token')

dispatch(actions.loginRequest({ appToken }))
}
} catch (error) {
setIsExecutingOAuth(false)
setPATLoadingState(undefined)

if (error.message === 'Canceled' || error.message === 'Timeout') return

const description = 'OAuth execution failed'
const description = 'Authentication failed'
console.error(description, error)

bugsnag.notify(error, { description })

Dialog.show('Login failed', `${error || ''}`)
}
}, [existingAppToken])
}, [existingAppToken, loggedGitHubUserId, loggedGitHubUsername])

const addPersonalAccessToken = useCallback(async () => {
setPATLoadingState('adding')
await loginWithGitHubPersonalAccessToken()
setPATLoadingState(undefined)
}, [loginWithGitHubPersonalAccessToken])

const removePersonalAccessToken = useCallback(async () => {
try {
setPATLoadingState('removing')
if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) {
dispatch(
actions.replacePersonalTokenDetails({
tokenDetails: undefined,
}),
)
} else {
try {
setPATLoadingState('removing')

const response = await axios.post(
constants.GRAPHQL_ENDPOINT,
{
query: `
const response = await axios.post(
constants.GRAPHQL_ENDPOINT,
{
query: `
mutation {
removeGitHubPersonalToken
}`,
},
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
)

const { data, errors } = await response.data
},
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
)

if (errors && errors[0] && errors[0].message)
throw new Error(errors[0].message)
const { data, errors } = await response.data

if (!(data && data.removeGitHubPersonalToken)) {
throw new Error('Not removed.')
}
if (errors?.[0]?.message) throw new Error(errors[0].message)

setPATLoadingState(undefined)
if (!data?.removeGitHubPersonalToken) {
throw new Error('Not removed.')
}

// dispatch(
// actions.replacePersonalTokenDetails({
// tokenDetails: undefined,
// }),
// )
setPATLoadingState(undefined)

// this is only necessary because we are not re-generating the appToken after removing the personal token,
// which causes the personal token to being added back after a page refresh
dispatch(actions.logout())
} catch (error) {
console.error(error)
bugsnag.notify(error)
// this is only necessary because we are not re-generating the appToken after removing the personal token,
// which causes the personal token to being added back after a page refresh
dispatch(actions.logout())
} catch (error) {
console.error(error)
bugsnag.notify(error)

setPATLoadingState(undefined)
Alert.alert(`Failed to remove personal token. \nError: ${error.message}`)
setPATLoadingState(undefined)
Dialog.show(
'Failed to remove personal token',
`Error: ${error?.message}`,
)
}
}
}, [existingAppToken])

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

Dialog.show('Login failed', `${error || ''}`)
Dialog.show('Login failed', `Error: ${error?.message}`)
}
}, [])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,28 @@ export const PrivateAccessSettings = React.memo<PrivateAccessSettingsProps>(
sharedStyles.paddingHorizontal,
]}
>
{githubPersonalTokenDetails?.token ? (
<ThemedText
color="foregroundColorMuted65"
style={sharedStyles.flex}
>
{new Array(githubPersonalTokenDetails.token.length)
.fill('*')
.join('')}
</ThemedText>
) : (
<ThemedText
color="foregroundColorMuted65"
style={[sharedStyles.flex, { fontStyle: 'italic' }]}
>
Useful to get private repo support
</ThemedText>
)}
{
githubPersonalTokenDetails?.token ? (
<ThemedText
color="foregroundColorMuted65"
style={sharedStyles.flex}
>
{new Array(githubPersonalTokenDetails.token.length)
.fill('*')
.join('')}
</ThemedText>
) : null
// <>
// <ThemedText
// color="foregroundColorMuted65"
// style={[sharedStyles.flex, { fontStyle: 'italic' }]}
// >
// Useful to get private repo support
// </ThemedText>
// <Spacer height={contentPadding} />
// </>
}
</View>

<Spacer height={contentPadding} />
</View>
)
},
Expand Down
15 changes: 14 additions & 1 deletion packages/components/src/redux/reducers/github/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import immer from 'immer'
import { REHYDRATE } from 'redux-persist'

import { GitHubAPIHeaders } from '@devhub/core'

import { Reducer } from '../../types'

export interface State {
baseApiUrl: string
baseUrl: string
headers?: GitHubAPIHeaders
}

const initialState: State = {
baseApiUrl: 'https://api.github.com',
baseUrl: 'https://github.com',
headers: {
pollInterval: undefined,
rateLimitLimit: undefined,
Expand All @@ -21,10 +27,17 @@ export const githubAPIReducer: Reducer<State> = (
action,
) => {
switch (action.type) {
case REHYDRATE as any: {
return {
...initialState,
...(action as any).payload?.github?.api,
}
}

case 'FETCH_SUBSCRIPTION_SUCCESS':
case 'FETCH_SUBSCRIPTION_FAILURE':
return immer(state, (draft) => {
if (!action.payload.github) return
if (!action.payload?.github?.headers) return

draft.headers = {
...draft.headers,
Expand Down
15 changes: 12 additions & 3 deletions packages/components/src/redux/reducers/github/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import _ from 'lodash'
import { constants, User } from '@devhub/core'
import { REHYDRATE } from 'redux-persist'

import { User } from '@devhub/core'
import { Reducer } from '../../types'

export interface State {
Expand All @@ -22,11 +22,20 @@ export const githubAuthReducer: Reducer<State> = (
action,
) => {
switch (action.type) {
case REHYDRATE as any: {
return {
...initialState,
...(action as any).payload?.github?.auth,
}
}

case 'LOGIN_SUCCESS':
return {
app: action.payload.user.github.app,
oauth: action.payload.user.github.oauth,
personal: action.payload.user.github.personal,
personal: constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN
? state.personal
: action.payload.user.github.personal,
user: action.payload.user.github.user,
}

Expand Down
1 change: 0 additions & 1 deletion packages/components/src/redux/reducers/github/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import _ from 'lodash'
import { combineReducers } from 'redux'

import { githubAPIReducer } from './api'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import immer from 'immer'
import _ from 'lodash'
import { REHYDRATE } from 'redux-persist'

import { Installation, LoadState, normalizeInstallations } from '@devhub/core'
import { Reducer } from '../../types'
Expand Down Expand Up @@ -41,6 +41,13 @@ export const githubInstallationsReducer: Reducer<State> = (
action,
) => {
switch (action.type) {
case REHYDRATE as any: {
return {
...initialState,
...(action as any).payload?.github?.installations,
}
}

case 'REFRESH_INSTALLATIONS_REQUEST':
return immer(state, (draft) => {
draft.lastFetchRequestAt = new Date().toISOString()
Expand Down
Loading

0 comments on commit 94d3f25

Please sign in to comment.