Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Personal Access Token As An Alternative Authentication Option #320

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v16.20.2
298 changes: 110 additions & 188 deletions packages/components/src/components/context/LoginHelpersContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ export const LoginHelpersContext =
})
LoginHelpersContext.displayName = 'LoginHelpersContext'

function handleAuthError(
error: unknown,
description = 'Authentication failed',
dialog: ReturnType<typeof useDialog>,
): void {
console.error(description)
if (error) console.error(error)

const err =
error instanceof Error
? error
: new Error(typeof error === 'string' ? error : 'Unknown error')
bugsnag.notify(err, { description })

if (err.message === 'Canceled' || err.message === 'Timeout') return
dialog.show('Login failed', err.message)
}

export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
const [isExecutingOAuth, setIsExecutingOAuth] = useState(false)
const [patLoadingState, setPATLoadingState] = useState<
Expand All @@ -91,7 +109,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
(state) => !!selectors.githubTokenSelector(state),
)

const Dialog = useDialog()
const dialog = useDialog()

const fullAccessRef = useRef(false)
const initialErrorRef = useRef(error)
Expand All @@ -116,14 +134,8 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
dispatch(actions.loginRequest({ appToken }))
setIsExecutingOAuth(false)
} catch (error) {
const description = 'OAuth execution failed'
console.error(description, error)
handleAuthError(error, 'OAuth execution failed', dialog)
setIsExecutingOAuth(false)

if (error.message === 'Canceled' || error.message === 'Timeout') return
bugsnag.notify(error, { description })

Dialog.show('Login failed', `${error || ''}`)
}
}

Expand All @@ -132,7 +144,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
> => {
let redirected = false
const token = await new Promise<string | undefined>((resolveToken) => {
Dialog.show(
dialog.show(
'Personal Access Token',
constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN
? 'It will be stored safely on your local device and only be sent directly to GitHub.'
Expand Down Expand Up @@ -191,7 +203,7 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
}

return token
}, [])
}, [dialog])

const loginWithGitHubPersonalAccessToken = useCallback(async () => {
try {
Expand All @@ -200,216 +212,128 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
const token = await promptForPersonalAcessToken()
if (!token) throw new Error('Canceled')

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)
setIsExecutingOAuth(true)
setPATLoadingState('adding')

if (!(response?.data?.id && response.data.login))
throw new Error('Invalid response')
// Validate token with GitHub API
const response = await axios.get(`${githubBaseApiUrl}/user`, {
headers: {
Authorization: `token ${token}`,
},
})

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}.`,
)
}
if (!(response?.data?.id && response.data.login)) {
throw new Error('Invalid response from GitHub API')
}

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.",
)
}
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.",
)
}

// In local-only mode, store the token and use it directly
if (constants.LOCAL_ONLY_PERSONAL_ACCESS_TOKEN) {
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 = 'Authentication failed'
console.error(description, error)

bugsnag.notify(error, { description })

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

const addPersonalAccessToken = useCallback(async () => {
await loginWithGitHubPersonalAccessToken()
}, [loginWithGitHubPersonalAccessToken])

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

const response = await axios.post(
// Use the personal access token as the app token
dispatch(actions.loginRequest({ appToken: token }))
} else {
// In server mode, exchange the token for an app token
const loginResponse = await axios.post(
constants.GRAPHQL_ENDPOINT,
{
query: `
mutation {
removeGitHubPersonalToken
}`,
loginWithPersonalAccessToken(input: { token: "${token}" }) {
appToken
}
}
`,
},
{
headers: getDefaultDevHubHeaders({ appToken: existingAppToken }),
},
{ headers: getDefaultDevHubHeaders({ appToken: existingAppToken }) },
)

const { data, errors } = await response.data
const { data, errors } = loginResponse.data

if (errors?.[0]?.message) throw new Error(errors[0].message)

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

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)
if (!data?.loginWithPersonalAccessToken?.appToken) {
throw new Error('Invalid response')
}

setPATLoadingState(undefined)
Dialog.show(
'Failed to remove personal token',
`Error: ${error?.message}`,
dispatch(
actions.loginRequest({
appToken: data.loginWithPersonalAccessToken.appToken,
}),
)
}
}
}, [existingAppToken])

// handle oauth flow without popup
// that passes the token via query string
useEffect(() => {
const currentURL = Linking.getCurrentURL()
const querystring = url.parse(currentURL).query || ''
const query = qs.parse(querystring)

if (!query.oauth) return

const params = getUrlParamsIfMatches(querystring, '')
if (!params) return

try {
const { appToken } = tryParseOAuthParams(params)
clearOAuthQueryParams()
if (!appToken) return

dispatch(actions.loginRequest({ appToken }))
setIsExecutingOAuth(false)
setPATLoadingState(undefined)
} catch (error) {
const description = 'OAuth execution failed'
console.error(description, error)

if (error.message === 'Canceled' || error.message === 'Timeout') return
bugsnag.notify(error, { description })

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

// auto start oauth flow after github app installation
useEffect(() => {
const handler = ({ url: uri }: { url: string }) => {
const querystring = url.parse(uri).query || ''
const query = qs.parse(querystring)

if (query.oauth) return
if (!query.installation_id) return

void loginWithGitHub()

setTimeout(() => {
clearQueryStringFromURL(['installation_id', 'setup_action'])
}, 500)
}

Linking.addEventListener('url', handler)

handler({ url: Linking.getCurrentURL() })

return () => {
Linking.removeEventListener('url', handler)
handleAuthError(error, 'Personal access token login failed', dialog)
setIsExecutingOAuth(false)
setPATLoadingState(undefined)
}
}, [])
}, [
dialog,
dispatch,
githubBaseApiUrl,
loggedGitHubUserId,
loggedGitHubUsername,
promptForPersonalAcessToken,
])

useEffect(() => {
if (!error || initialErrorRef.current === error) return

const message = error && error.message
Dialog.show(
'Login failed',
`Please try again. ${message ? ` \nError: ${message}` : ''}`,
)
}, [error])

useEffect(() => {
if (!hasGitHubToken && !!existingAppToken && !isLoggingIn) {
dispatch(actions.logout())
const removePersonalAccessToken = useCallback(async () => {
try {
setPATLoadingState('removing')
dispatch(actions.replacePersonalTokenDetails({ tokenDetails: undefined }))
await Promise.resolve()
setPATLoadingState(undefined)
} catch (error) {
handleAuthError(error, 'Failed to remove personal access token', dialog)
setPATLoadingState(undefined)
}
}, [!hasGitHubToken && !!existingAppToken && !isLoggingIn])
}, [dialog, dispatch])

const value = useMemo(
const value = useMemo<LoginHelpersProviderState>(
() => ({
addPersonalAccessToken,
addPersonalAccessToken: loginWithGitHubPersonalAccessToken,
fullAccessRef,
isExecutingOAuth,
isLoggingIn,
Expand All @@ -419,12 +343,10 @@ export function LoginHelpersProvider(props: LoginHelpersProviderProps) {
removePersonalAccessToken,
}),
[
addPersonalAccessToken,
fullAccessRef,
loginWithGitHubPersonalAccessToken,
isExecutingOAuth,
isLoggingIn,
loginWithGitHub,
loginWithGitHubPersonalAccessToken,
patLoadingState,
removePersonalAccessToken,
],
Expand Down
Loading