mirror of
https://github.com/clinicjs/node-clinic.git
synced 2026-01-18 16:22:03 +00:00
* 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
212 lines
5.7 KiB
JavaScript
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
|