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