@flowfuse/nr-assistant
Version:
FlowFuse Node-RED Expert plugin
278 lines (259 loc) • 9.53 kB
JavaScript
const crypto = require('node:crypto')
const path = require('node:path')
const { readFile, writeFile } = require('node:fs/promises')
const { existsSync } = require('node:fs')
const base64url = require('base64url')
const store = require('./store')
const got = require('got').default
let baseURL = 'https://app.flowfuse.com'
const authorizationURL = () => `${baseURL}/account/authorize`
const tokenURL = () => `${baseURL}/account/token`
let activeTokens = { }
const activeTimers = { }
let assistant = null
let tokenFile
let RED
/**
* Initialise the auth handling for standalone FF Assistant mode
* @param {*} _RED
*/
async function init (_RED) {
RED = _RED
baseURL = RED.settings.flowfuse?.assistant?.url || 'https://app.flowfuse.com'
tokenFile = path.join(RED.settings.userDir, '.flowfuse-assistant.json')
if (existsSync(tokenFile)) {
try {
const data = await readFile(tokenFile, 'utf8')
if (data) {
activeTokens = JSON.parse(data)
// We currently only support a single user '_'
const token = activeTokens._
if (!token || token.expires_at < Date.now()) {
RED.log.info('FlowFuse Assistant: access has expired, please log in again')
await deleteUserToken('_')
} else {
setupRefreshTimer('_')
}
}
} catch (err) {
RED.log.error('FlowFuse Assistant: Failed to load access tokens')
}
}
}
/**
* Write tokens to file
*/
async function saveTokens () {
await writeFile(tokenFile, JSON.stringify(activeTokens), 'utf8')
}
/**
* Update a user token, setup refresh timer and save to file
* @param {*} user
* @param {*} token
*/
async function setUserToken (user, token) {
activeTokens[user] = token
setupRefreshTimer(user)
await saveTokens()
}
/**
* Setup the refresh timer for the token. User tokens have a relatively short expiry time, but can be refreshed before expiry
* to provide a longer session.
* @param {*} user
*/
function setupRefreshTimer (user) {
const token = activeTokens[user]
if (!token.expires_at) {
token.expires_at = Date.now() + (token.expires_in * 1000)
}
const refreshInterval = token.expires_at - Date.now() - 10000 // refresh with 10 secs to spare
if (refreshInterval > 0) {
activeTimers[user] = setTimeout(async () => {
try {
const newTokens = await refreshToken(token)
newTokens.expires_at = Date.now() + (newTokens.expires_in * 1000)
await setUserToken(user, newTokens)
} catch (err) {
// Failed to refresh token - remove it and disable the agent
await deleteUserToken(user)
assistant.RED.log.warn('Failed to refresh FlowFuse Assistant access token')
reinitialiseAssistant()
}
}, refreshInterval)
}
}
/**
* When running in a managed FF environment, the tokens are provided statically via settings.
* This is used to insert the token into the auth table so we have a single place to retrieve
* tokens from.
* @param {*} token
*/
function setStaticToken (token) {
// A token provided by settings file - does not need refresh logic
activeTokens._ = {
access_token: token
}
}
/**
* Get the current user token
*/
function getUserToken () {
// Only single shared user supported currently
return activeTokens._?.access_token
}
/**
* Delete a users token, clears up refresh timers and updates saved tokens
* @param {*} user
*/
async function deleteUserToken (user) {
const token = activeTokens[user]
if (token) {
clearTimeout(activeTimers[user])
}
delete activeTokens[user]
delete activeTimers[user]
saveTokens()
}
/**
* Make an API request to refresh the user token
* @param {*} token
*/
async function refreshToken (token) {
const params = {
grant_type: 'refresh_token',
client_id: 'ff-plugin',
refresh_token: token.refresh_token
}
return got.post(tokenURL(), {
headers: {
'Content-Type': 'application/json'
},
json: params
}).then(async (result) => {
return JSON.parse(result.body)
})
}
function setupRoutes (_assistant, RED) {
assistant = _assistant
RED.httpAdmin.get('/nr-assistant/auth/authorize', (request, response) => {
const existingRequest = store.getRequest(request.query.s)
if (!existingRequest) {
return response.send(404)
}
const verifier = base64url(crypto.randomBytes(32))
const scope = 'ff-assistant'
store.storeRequest({ ...existingRequest, verifier, scope })
const params = {}
params.client_id = 'ff-plugin'
params.scope = scope
params.response_type = 'code'
params.state = existingRequest.state
params.code_challenge = base64url(crypto.createHash('sha256').update(verifier).digest())
params.code_challenge_method = 'S256'
params.redirect_uri = existingRequest.redirect_uri
const authURL = new URL(authorizationURL())
authURL.search = new URLSearchParams(params)
response.redirect(authURL.toString())
})
RED.httpAdmin.get('/nr-assistant/auth/callback', async (request, response) => {
if (request.query.error) {
const postMessage = JSON.stringify({ code: 'flowfuse-auth-error', error: request.query.error, message: request.query.errorDescription })
response.send(`
<html><head>
<script>
if (window.opener) {
window.opener.postMessage('${postMessage}', '*')
window.close()
}
</script>
</head><body>Failed to complete authentication.</body></html>
`)
return
}
if (!request.query.code || !request.query.state) {
response.send('Failed to complete authentication')
return
}
const originalRequest = store.getRequest(request.query.state)
if (!originalRequest) {
response.send('Failed to complete authentication - unknown state')
return
}
const params = {}
params.grant_type = 'authorization_code'
params.code = request.query.code
params.redirect_uri = originalRequest.redirect_uri
params.client_id = 'ff-plugin'
params.code_verifier = originalRequest.verifier
got.post(tokenURL(), {
headers: {
'Content-Type': 'application/json'
},
json: params
}).then(async (result) => {
const tokens = JSON.parse(result.body)
await setUserToken(originalRequest.user, tokens)
const postMessage = JSON.stringify({ code: 'flowfuse-auth-complete', state: originalRequest.state })
response.send(`
<html><head>
<script>
if (window.opener) {
window.opener.postMessage('${postMessage}', '*')
window.close()
}
</script>
</head><body>Success! You're connected to FlowFuse. You can now close this window to continue.</body></html>
`)
// Now we have a token for the user, reinitialise the assistant to enable it
reinitialiseAssistant()
}).catch((error) => {
console.warn('request failed', error)
})
})
RED.httpAdmin.use('/nr-assistant/*', RED.auth.needsPermission('flowfuse.write'))
RED.httpAdmin.post('/nr-assistant/auth/start', async (request, response) => {
// This request is made from the editor, so will have the Node-RED user attached.
// Generate the login url for the auth pop-up window
// if (request.body.forgeURL) {
// request.body.forgeURL = request.body.forgeURL.replace(/\/$/, '')
// settings.set('forgeURL', request.body.forgeURL)
// }
// Ping the server to check it is responsive and looks like a valid FF endpoint
got.get(`${baseURL}/api/v1/settings`).then(result => {
const state = base64url(crypto.randomBytes(16))
const redirect = request.body.editorURL + (request.body.editorURL.endsWith('/') ? '' : '/') + 'nr-assistant/auth/callback'
store.storeRequest({
user: '_',
state,
redirect_uri: redirect
})
const authPath = 'nr-assistant/auth/authorize?s=' + state
response.send({ path: authPath, state })
}).catch(err => {
RED.log.error(`[nr-assistant] Failed to connect to server: ${err.toString()}`)
response.send({ error: err.toString(), code: 'connect_failed' })
})
})
RED.httpAdmin.post('/nr-assistant/auth/disconnect', async (request, response) => {
deleteUserToken('_')
response.send({ })
})
}
async function reinitialiseAssistant () {
if (assistant && assistant.RED) {
const newSettings = await require('../settings').getSettings(assistant.RED)
assistant.init(assistant.RED, newSettings).then(() => {
// All good, the assistant is initialized.
// Any info messages made during initialization are logged in the assistant module
}).catch((error) => {
console.error(error)
assistant.RED.log.error('Failed to initialize FlowFuse Assistant Plugin:', error)
})
}
}
module.exports = {
init,
setupRoutes,
getUserToken,
setStaticToken
}