node-clinic/lib/authenticate.js
greenkeeper[bot] a9cc2bf55a Update open to the latest version 🚀 (#192)
* fix(package): update open to version 7.0.0

* use the new `url` option
2019-10-17 15:00:03 +02:00

226 lines
6.2 KiB
JavaScript

'use strict'
const open = require('open')
const path = require('path')
const fs = require('fs')
const { promisify } = require('util')
const { homedir } = require('os')
const randtoken = require('rand-token')
const websocket = require('websocket-stream')
const split2 = require('split2')
const { ReadableStreamBuffer } = require('stream-buffers')
const { URL } = require('url')
const pump = require('pump')
const jwt = require('jsonwebtoken')
const unlink = promisify(fs.unlink)
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
const credentialsPath = process.env.CLINIC_CREDENTIALS ||
path.join(homedir(), '.node-clinic-rc')
const CUSTOM_CLAIM_NAMESPACE = 'https://upload.clinicjs.org/'
const ASK_CLAIM_KEY = `${CUSTOM_CLAIM_NAMESPACE}askTermsAccepted`
/**
* Load the JWT for an Upload Server URL.
*/
async function loadToken (url) {
let tokens
try {
const data = await readFile(credentialsPath)
tokens = JSON.parse(data)
} catch (err) {}
return tokens && tokens[url]
}
/**
* Check that a JWT has not expired.
*/
function validateToken (token) {
const header = jwt.decode(token)
const now = Math.floor(Date.now() / 1000)
return header && header.exp > now
}
/**
* Check that a JWT has Ask terms accepted.
*/
function validateAskPermission (token) {
const header = jwt.decode(token)
return validateToken(token) && header[ASK_CLAIM_KEY]
}
/**
* Get the session data for an Upload Server URL.
*/
async function getSession (url) {
const token = await loadToken(url)
if (!token) return null
const header = jwt.decode(token)
if (!header) return null
const now = Math.floor(Date.now() / 1000)
if (header.exp <= now) return null
return header
}
/**
* Store the JWT for an Upload Server URL in the credentials file.
*/
async function saveToken (url, token) {
let tokens
try {
const data = await readFile(credentialsPath)
tokens = JSON.parse(data)
} catch (err) {}
// if it was empty or contained `null` for some reason
if (typeof tokens !== 'object' || !tokens) {
tokens = {}
}
tokens[url] = token
await writeFile(credentialsPath, JSON.stringify(tokens, null, 2))
}
/**
* Get the JWT token from the Upload server specified by the URL.
* - create a random tokens
* - open a websocket on the server
* - push the token through the websocket
* - Open the browser on `http://localhost:3000/auth/token/${cliToken}/(?ask=1)(&private=1)`
* - Get the JWT from the web websocket
*/
function authenticateViaBrowser (url, isAskFlow = false, isPrivate = false, clearSession = false) {
return new Promise((resolve, reject) => {
const cliToken = randtoken.generate(128)
const parsedURL = new URL(url)
/* istanbul ignore next */
parsedURL.protocol = url && url.toLowerCase().includes('https') ? 'wss:/' : 'ws:/'
const wsUrl = parsedURL.toString()
const ws = websocket(wsUrl)
const readBuffer = new ReadableStreamBuffer()
readBuffer.put(cliToken)
pump(readBuffer, ws, split2(), err => err ? reject(err) : /* istanbul ignore next */ null)
.on('data', (authToken) => {
let err
if (!authToken) {
err = new Error('Authentication failed. No token obtained.')
} else if (typeof authToken === 'string' && authToken.toLowerCase() === 'timeout') {
err = new Error('Authentication timed out.')
}
err ? reject(err) : resolve(authToken)
ws.destroy()
})
ws.once('connect', () => {
const cliLoginUrl = generateBrowserLoginUrl(url, cliToken, {
isAskFlow,
isPrivate,
clearSession
})
if (process.env.SSH_CLIENT || process.env.SSH_TTY) {
console.log('Authentication required. Please visit this URL to sign in:')
console.log(` ${cliLoginUrl}`)
return
}
console.log('Authentication required. Opening the login page in a browser...')
// Open the url in the default browser
open(cliLoginUrl, { url: true, wait: false }).then((child) => {
child.on('exit', (code) => {
if (code !== 0) {
console.log('Could not find a browser. Visit this URL to authenticate:')
console.log(` ${cliLoginUrl}`)
}
})
})
})
})
}
async function authenticate (url, opts = {}) {
const mockJWT = process.env.CLINIC_JWT
const mockFail = process.env.CLINIC_MOCK_AUTH_FAIL
if (mockJWT) {
// store it if we ALSO specified a credentials file,
// Normally, you shouldn't use both CLINIC_JWT and CLINIC_CREDENTIALS at the same time,
// but this is a useful case for our tests
if (process.env.CLINIC_CREDENTIALS) {
await saveToken(url, mockJWT)
}
return mockJWT
}
if (mockFail) {
throw new Error('Auth artificially failed')
}
// Use cached token if it's not expired
const existingJWT = await loadToken(url)
let clearSession = false
if (existingJWT && validateToken(existingJWT)) {
if (!opts.ask || validateAskPermission(existingJWT)) {
return existingJWT
} else if (opts.ask) {
clearSession = true
}
}
const newJWT = await authenticateViaBrowser(url, opts.ask, opts.private, clearSession)
await saveToken(url, newJWT)
return newJWT
}
async function getSessions () {
let tokens = {}
try {
const data = await readFile(credentialsPath)
tokens = JSON.parse(data)
} catch (err) {}
const sessions = {}
Object.keys(tokens).forEach((url) => {
sessions[url] = validateToken(tokens[url])
? jwt.decode(tokens[url])
: null
})
return sessions
}
function logout (url) {
return saveToken(url, undefined)
}
function removeSessions () {
return unlink(credentialsPath)
}
function generateBrowserLoginUrl (base, token, opts = {}) {
const url = new URL(`${base}/auth/token/${token}/`)
if (opts.isAskFlow) {
url.searchParams.append('ask', '1')
}
if (opts.isPrivate || opts.isAskFlow) {
url.searchParams.append('private', '1')
}
if (opts.clearSession) {
url.searchParams.append('clearSession', '1')
}
return url.toString()
}
module.exports = authenticate
module.exports.getSessions = getSessions
module.exports.getSession = getSession
module.exports.removeSessions = removeSessions
module.exports.logout = logout