node-clinic/lib/authenticate.js
Renée Kooi 3cbe86ec5d
Next (#118)
* clinic ask (#66)

The `ask` command is used to upload on the private area, e.g.: 
```
clinic ask --upload-url=http://localhost:3000 11213.clinic-bubbleprof
```
This will:
- Start the authentication on upload server to obtain a JWT token
- Upload the data to the protected API `/protected/data` on upload server

On the server side, the API can extract the user email from the JWT token to correctly identify the "private" folder for the user

* Authenticate for public uploads and support private uploads without `ask`ing (#102)

`clinic upload xyz.clinic-doctor` now also requires authentication.

A new `clinic upload --private` flag uploads to your private area.

`clinic ask` does `clinic upload --private` and then calls a currently-noop function that can be implemented once we have a `/ask` endpoint on the server.

* Store auth tokens in ~/.node-clinic-rc (#108)

Stores the JWT in ~/.node-clinic-rc after logging in. ~/.node-clinic-rc is a JSON file with upload URLs as keys, JWTs as values.

Use `clinic login` to login manually. Optionally specify an `--upload-url`.
Use `clinic logout` to logout manually. Optionally specify an `--upload-url`. Add `--all` to log out of all Clinic Upload servers, this deletes the ~/.node-clinic-rc file.
Use `clinic user` to show a list of current sessions. Optionally specify an `--upload-url` to only show that session.

You can use the `CLINIC_CREDENTIALS` environment variable to point to a different file. I added this for tests, maybe it's also useful in programmatic environments and warrants docs?

`clinic upload` and `clinic ask` automatically do what `clinic login` does at the start.

* Feature/ask auth flag (#114)

* [666] - Add ask param flag to login URL when authenticating using ask command

* [666] - Factor user terms acceptance into CLI login when validating JWT payload against upload type

* Implement ask with placeholder message (#115)

Means we'll have to reply first to figure out what someone needs help with but it's better than not getting a message at all

* 3.0.0-beta.0@next

* Fix/private public auth redirect (#116)

* Re #99 - Pass flag for private uploads to login URL so app can differentiate intent

* Re #97 - Open new tab on upload callback and create flag to prevent this behaviour if desired

* Re #97 - Update browser open flag to use recommended minimist syntax

* Revert "Disable clinic upload in old CLI. (#117)"

This reverts commit 75f80771b4741a4927788a2d492566d04416e1b7.

* Update tool versions.

* remove weird test? unsure what this was for
2019-02-13 14:34:53 +01:00

212 lines
5.7 KiB
JavaScript

'use strict'
const opn = require('opn')
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', () => {
console.log('Authentication required. Opening the login page in a browser...')
// Open the url in the default browser
const cliLoginUrl = generateBrowserLoginUrl(url, cliToken, {
isAskFlow,
isPrivate,
clearSession
})
opn(cliLoginUrl, { wait: false })
})
})
}
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