UNPKG

13.2 kBJavaScriptView Raw
1'use strict'
2
3const { promisify } = require('util')
4const read = promisify(require('read'))
5const fetch = require('node-fetch')
6const appCfg = require('application-config')
7const querystring = require('querystring')
8const ora = require('ora')
9
10const defaultUA = 'Magic Node.js application that does magic things with ghauth'
11const defaultScopes = []
12const defaultPasswordReplaceChar = '\u2714'
13
14// split a string at roughly `len` characters, being careful of word boundaries
15function newlineify (len, str) {
16 let s = ''
17 let l = 0
18 const sa = str.split(' ')
19
20 while (sa.length) {
21 if (l + sa[0].length > len) {
22 s += '\n'
23 l = 0
24 } else {
25 s += ' '
26 }
27 s += sa[0]
28 l += sa[0].length
29 sa.splice(0, 1)
30 }
31
32 return s
33}
34
35function sleep (s) {
36 const ms = s * 1000
37 return new Promise(resolve => setTimeout(resolve, ms))
38}
39
40function basicAuthHeader (user, pass) {
41 return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`
42}
43
44// prompt the user for credentials
45async function deviceFlowPrompt (options) {
46 const scopes = options.scopes || defaultScopes
47 const passwordReplaceChar = options.passwordReplaceChar || defaultPasswordReplaceChar
48 const deviceCodeUrl = 'https://github.com/login/device/code'
49 const fallbackDeviceAuthUrl = 'https://github.com/login/device'
50 const accessTokenUrl = 'https://github.com/login/oauth/access_token'
51 const oauthAppsBaseUrl = 'https://github.com/settings/connections/applications'
52 const userEndpointUrl = 'https://api.github.com/user'
53 const patUrl = 'https://github.com/settings/tokens'
54
55 const defaultReqOptions = {
56 headers: {
57 'User-Agent': options.userAgent || defaultUA,
58 Accept: 'application/json'
59 },
60 method: 'post'
61 }
62
63 // get token data from device flow, or interrupt to try PAT flow
64 const deviceFlowSpinner = ora()
65 let endDeviceFlow = false // race status indicator for deviceFlowInterrupt and deviceFlow
66 let interruptHandlerRef // listener reference for deviceFlowInterrupt
67 let tokenData
68
69 if (!options.noDeviceFlow) {
70 tokenData = await Promise.race([deviceFlow(), deviceFlowInterrupt()])
71 process.stdin.off('keypress', interruptHandlerRef) // disable keypress listener when race finishes
72
73 // try the PAT flow if interrupted
74 if (tokenData === false) {
75 deviceFlowSpinner.warn('Device flow canceled.')
76 tokenData = await patFlow()
77 }
78 } else {
79 console.log('Personal access token auth for Github.')
80 tokenData = await patFlow()
81 }
82
83 return tokenData
84
85 // prompt for a personal access token with simple validation
86 async function patFlow () {
87 let patMsg = `Enter a 40 character personal access token generated at ${patUrl} ` +
88 (scopes.length ? `with the following scopes: ${scopes.join(', ')}` : '(no scopes necessary)') + '\n' +
89 'PAT: '
90 patMsg = newlineify(80, patMsg)
91 const pat = await read({ prompt: patMsg, silent: true, replace: passwordReplaceChar })
92 if (!pat) throw new TypeError('Empty personal access token received.')
93 if (pat.length !== 40) throw new TypeError('Personal access tokens must be 40 characters long')
94 const tokenData = { token: pat }
95
96 return supplementUserData(tokenData)
97 }
98
99 // cancel deviceFlow if user presses enter``
100 function deviceFlowInterrupt () {
101 return new Promise((resolve, reject) => {
102 process.stdin.on('keypress', keyPressHandler)
103
104 interruptHandlerRef = keyPressHandler
105 function keyPressHandler (letter, key) {
106 if (key.name === 'return') {
107 endDeviceFlow = true
108 resolve(false)
109 }
110 }
111 })
112 }
113
114 // create a device flow session and return tokenData
115 async function deviceFlow () {
116 let currentInterval
117 let currentDeviceCode
118 let currentUserCode
119 let verificationUri
120
121 await initializeNewDeviceFlow()
122
123 const authPrompt = ' Authorize with Github by opening this URL in a browser:' +
124 '\n' +
125 '\n' +
126 ` ${verificationUri}` +
127 '\n' +
128 '\n' +
129 ' and enter the following User Code:\n' +
130 ' (or press ⏎ to enter a personal access token)\n'
131
132 console.log(authPrompt)
133
134 deviceFlowSpinner.start(`User Code: ${currentUserCode}`)
135
136 const accessToken = await pollAccessToken()
137 if (accessToken === false) return false // interrupted, don't return anything
138
139 const tokenData = { token: accessToken.access_token, scope: accessToken.scope }
140 deviceFlowSpinner.succeed(`Device flow complete. Manage at ${oauthAppsBaseUrl}/${options.clientId}`)
141
142 return supplementUserData(tokenData)
143
144 async function initializeNewDeviceFlow () {
145 const deviceCode = await requestDeviceCode()
146
147 if (deviceCode.error) {
148 let error
149 switch (deviceCode.error) {
150 case 'Not Found': {
151 error = new Error('Not found: is the clientId correct?')
152 break
153 }
154 case 'unauthorized_client': {
155 error = new Error(`${deviceCode.error_description} Did you enable 'Device authorization flow' for your oAuth application?`)
156 break
157 }
158 default: {
159 error = new Error(deviceCode.error_description || deviceCode.error)
160 break
161 }
162 }
163 error.data = deviceCode
164 throw error
165 }
166
167 if (!(deviceCode.device_code || deviceCode.user_code)) {
168 const error = new Error('No device code from GitHub!')
169 error.data = deviceCode
170 throw error
171 }
172
173 currentInterval = deviceCode.interval || 5
174 verificationUri = deviceCode.verification_uri || fallbackDeviceAuthUrl
175 currentDeviceCode = deviceCode.device_code
176 currentUserCode = deviceCode.user_code
177 }
178
179 async function pollAccessToken () {
180 let endDeviceFlowDetected
181
182 while (!endDeviceFlowDetected) {
183 await sleep(currentInterval)
184 const data = await requestAccessToken(currentDeviceCode)
185
186 if (data.access_token) return data
187 if (data.error === 'authorization_pending') continue
188 if (data.error === 'slow_down') currentInterval = data.interval
189 if (data.error === 'expired_token') {
190 deviceFlowSpinner.text('User Code: Updating...')
191 await initializeNewDeviceFlow()
192 deviceFlowSpinner.text(`User Code: ${currentUserCode}`)
193 }
194 if (data.error === 'unsupported_grant_type') throw new Error(data.error_description || 'Incorrect grant type.')
195 if (data.error === 'incorrect_client_credentials') throw new Error(data.error_description || 'Incorrect clientId.')
196 if (data.error === 'incorrect_device_code') throw new Error(data.error_description || 'Incorrect device code.')
197 if (data.error === 'access_denied') throw new Error(data.error_description || 'The authorized user canceled the access request.')
198 endDeviceFlowDetected = endDeviceFlow // update inner interrupt scope
199 }
200
201 // interrupted
202 return false
203 }
204 }
205
206 function requestAccessToken (deviceCode) {
207 const query = {
208 client_id: options.clientId,
209 device_code: deviceCode,
210 grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
211 }
212
213 return fetch(`${accessTokenUrl}?${querystring.stringify(query)}`, defaultReqOptions).then(req => req.json())
214 }
215
216 function requestDeviceCode () {
217 const query = {
218 client_id: options.clientId
219 }
220 if (scopes.length) query.scope = scopes.join(' ')
221
222 return fetch(`${deviceCodeUrl}?${querystring.stringify(query)}`, defaultReqOptions).then(req => req.json())
223 }
224
225 function requestUser (token) {
226 const reqOptions = {
227 headers: {
228 'User-Agent': options.userAgent || defaultUA,
229 Accept: 'application/vnd.github.v3+json',
230 Authorization: `token ${token}`
231 },
232 method: 'get'
233 }
234
235 return fetch(userEndpointUrl, reqOptions).then(req => req.json())
236 }
237
238 async function supplementUserData (tokenData) {
239 // Get user login info
240 const userSpinner = ora().start('Retrieving user...')
241 try {
242 const user = await requestUser(tokenData.token)
243 if (!user || !user.login) {
244 userSpinner.fail('Failed to retrieve user info.')
245 } else {
246 userSpinner.succeed(`Authorized for ${user.login}`)
247 }
248 tokenData.user = user.login
249 } catch (e) {
250 userSpinner.fail(`Failed to retrieve user info: ${e.message}`)
251 }
252
253 return tokenData
254 }
255}
256
257// prompt the user for credentials
258async function enterprisePrompt (options) {
259 const defaultNote = 'Node.js command-line app with ghauth'
260 const promptName = options.promptName || 'Github Enterprise'
261 const accessTokenUrl = options.accessTokenUrl
262 const scopes = options.scopes || defaultScopes
263 const usernamePrompt = options.usernamePrompt || `Your ${promptName} username:`
264 const tokenQuestionPrompt = options.tokenQuestionPrompt || 'This appears to be a personal access token, is that correct? [y/n] '
265 const passwordReplaceChar = options.passwordReplaceChar || defaultPasswordReplaceChar
266 const authUrl = options.authUrl || 'https://api.github.com/authorizations'
267 let passwordPrompt = options.passwordPrompt
268
269 if (!passwordPrompt) {
270 let patMsg = `You may either enter your ${promptName} password or use a 40 character personal access token generated at ${accessTokenUrl} ` +
271 (scopes.length ? `with the following scopes: ${scopes.join(', ')}` : '(no scopes necessary)')
272 patMsg = newlineify(80, patMsg)
273 passwordPrompt = `${patMsg}\nYour ${promptName} password:`
274 }
275
276 // username
277
278 const user = await read({ prompt: usernamePrompt })
279 if (user === '') {
280 return
281 }
282
283 // password || token
284
285 const pass = await read({ prompt: passwordPrompt, silent: true, replace: passwordReplaceChar })
286
287 if (pass.length === 40) {
288 // might be a token?
289 do {
290 const yorn = await read({ prompt: tokenQuestionPrompt })
291
292 if (yorn.toLowerCase() === 'y') {
293 // a token, apparently we have everything
294 return { user, token: pass }
295 }
296
297 if (yorn.toLowerCase() === 'n') {
298 break
299 }
300 } while (true)
301 }
302
303 // username + password
304 // check for 2FA, this may trigger an SMS if the user set it up that way
305 const otpReqOptions = {
306 headers: {
307 'User-Agent': options.userAgent || defaultUA,
308 Authorization: basicAuthHeader(user, pass)
309 },
310 method: 'POST'
311 }
312
313 const response = await fetch(authUrl, otpReqOptions)
314 const otpHeader = response.headers.get('x-github-otp')
315 response.arrayBuffer() // exaust response body
316
317 let otp
318 if (otpHeader && otpHeader.indexOf('required') > -1) {
319 otp = await read({ prompt: 'Your GitHub OTP/2FA Code (required):' })
320 }
321
322 const currentDate = new Date().toJSON()
323 const patReqOptions = {
324 headers: {
325 'User-Agent': options.userAgent || defaultUA,
326 'Content-type': 'application/json',
327 Authorization: basicAuthHeader(user, pass)
328 },
329 method: 'POST',
330 body: JSON.stringify({
331 scopes,
332 note: `${(options.note || defaultNote)} (${currentDate})`
333 })
334 }
335 if (otp) patReqOptions.headers['X-GitHub-OTP'] = otp
336
337 const data = await fetch(authUrl, patReqOptions).then(res => res.json())
338
339 if (data.message) {
340 const error = new Error(data.message)
341 error.data = data
342 throw error
343 }
344
345 if (!data.token) {
346 throw new Error('No token from GitHub!')
347 }
348
349 return { user, token: data.token, scope: scopes.join(' ') }
350}
351
352function isEnterprise (authUrl) {
353 if (!authUrl) return false
354 const parsedAuthUrl = new URL(authUrl)
355 if (parsedAuthUrl.host === 'github.com') return false
356 if (parsedAuthUrl.host === 'api.github.com') return false
357 return true
358}
359
360async function auth (options) {
361 if (typeof options !== 'object') {
362 throw new TypeError('ghauth requires an options argument')
363 }
364
365 let config
366
367 if (!options.noSave) {
368 if (typeof options.configName !== 'string') {
369 throw new TypeError('ghauth requires an options.configName property')
370 }
371
372 config = appCfg(options.configName)
373 const authData = await config.read()
374 if (authData && authData.user && authData.token) {
375 // we had it saved in a config file
376 return authData
377 }
378 }
379
380 let tokenData
381 if (!isEnterprise(options.authUrl)) {
382 if (typeof options.clientId !== 'string' && !options.noDeviceFlow) {
383 throw new TypeError('ghauth requires an options.clientId property')
384 }
385
386 tokenData = await deviceFlowPrompt(options) // prompt the user for data
387 } else {
388 tokenData = await enterprisePrompt(options) // prompt the user for data
389 }
390 if (!(tokenData || tokenData.token || tokenData.user)) throw new Error('Authentication error: token or user not generated')
391
392 if (options.noSave) {
393 return tokenData
394 }
395
396 process.umask(0o077)
397 await config.write(tokenData)
398
399 process.stdout.write(`Wrote access token to "${config.filePath}"\n`)
400
401 return tokenData
402}
403
404module.exports = function ghauth (options, callback) {
405 if (typeof callback !== 'function') {
406 return auth(options) // promise, it can be awaited
407 }
408
409 auth(options).then((data) => callback(null, data)).catch(callback)
410}