diff --git a/functions/app.js b/functions/app.js index 1ef1132..a32a3fd 100644 --- a/functions/app.js +++ b/functions/app.js @@ -1,268 +1,20 @@ 'use strict'; -const functions = require('firebase-functions'); -const admin = require("firebase-admin"); +const functions = require('firebase-functions/v1'); const OAuth2Server = require("oauth2-server"); const fetch = require('node-fetch'); +const { showError } = require('./utils'); +const { sync, query, execute } = require('./intents'); + const requestSyncEndpoint = 'https://homegraph.googleapis.com/v1/devices:requestSync?key='; const reportStateEndpoint = 'https://homegraph.googleapis.com/v1/devices?key='; -const getFromFirestore = (collection, doc) => { - return admin.firestore().collection(collection).doc(doc); -}; -const getFromDatabase = (refkey, pkey) => { - return admin.database().ref(refkey + '/' + pkey).once('value'); -}; - -const Model = {}; -const getPersonalDevices = (personal, personalId) => { - let personalId_key = undefined; - switch(personal) { - case "group_devices": - personalId_key = 'groupId'; break; - case "user_devices": - personalId_key = 'userId'; break; - default: - throw new SyntaxError('personal required'); - } - return admin.firestore().collection(personal).where(personalId_key, '==', personalId).get().then((querysnap) => { - const devicePromises = []; - querysnap.forEach((docsnap) => { - const docdata = docsnap.data(); - if(!docdata.deviceId && !docdata.deviceReference) { - throw new SyntaxError('require deviceId or deviceReference in group_devices.'); - } - let deviceReference; - if(docdata.deviceReference) { - deviceReference = docdata.deviceReference.get(); - } else { - deviceReference = Model.getDevice(docdata.deviceId); - } - devicePromises.push(deviceReference.then((devsnap) => { - return { - id: docsnap.id, - name: docdata.name, - data: devsnap.data() - }; - })); - }); - return Promise.all(devicePromises); - }); -}; -Model.getGroupDevices = (groupId) => { - return getPersonalDevices('group_devices', groupId); -}; -Model.getUserDevices = (userId) => { - return getPersonalDevices('user_devices', userId); -}; -Model.getDevice = (deviceId) => { - return getFromFirestore('devices', deviceId).get().then((docsnap) => { - if(docsnap && docsnap.exists) { - return { - id: docsnap.id, - data: docsnap.data() - } - } - return {}; - }); -}; -Model.getRemote = (remoteId) => { - return getFromFirestore('remotes', remoteId).get().then((docsnap) => { - if(docsnap && docsnap.exists) { - return { - id: docsnap.id, - data: docsnap.data() - } - } - return {}; - }); -}; -Model.getUser = (userId) => { - return getFromFirestore('users', userId).get().then((docsnap) => { - if(docsnap && docsnap.exists) { - return { - id: docsnap.id, - data: docsnap.data() - } - } - return {}; - }); -}; -Model.getGroup = (groupId) => { - return getFromFirestore('groups', groupId).get().then((docsnap) => { - if(docsnap && docsnap.exists) { - return { - id: docsnap.id, - data: docsnap.data() - } - } - return {}; - }); -}; -Model.getCommands = (indivDeviceId) => { - return getFromDatabase('commands', indivDeviceId).then((snap) => { - return { - id: indivDeviceId, - data: snap.val() - }; - }); -}; -Model.setCommands = (indivDeviceId, commandData) => { - return admin.database().ref('commands/' + indivDeviceId).set(commandData); -}; -Model.getStates = (indivDeviceId) => { - return getFromDatabase('states', indivDeviceId).then((snap) => { - return { - id: indivDeviceId, - data: snap.val() - }; - }); -}; -Model.setStates = (indivDeviceId, statesData) => { - return admin.database().ref('states/' + indivDeviceId).update(statesData); -}; -Model.getGroupDevicesByUserId = (userId) => { - return admin.firestore().collection('groups').select(userId).get().then((querysnap) => { - const groupDevicePromises = []; - querysnap.forEach((docsnap) => { - if(docsnap && docsnap.exists) - groupDevicePromises.push(Model.getGroupDevices(docsnap.id)); - }); - return Promise.all(groupDevicePromises); - }); -}; - -const valuniq = (obj) => { - const arrobj = {}; - for(let i = 0; i< obj.length; i++) { - arrobj[obj[i]['id']] = obj[i]; - } - const newret = []; - for(let key in arrobj) { - newret.push(arrobj[key]); - } - return newret; -} -const flatten = (arr) => Array.isArray(arr) ? [].concat.apply([], arr.map(flatten)) : arr; - -const sync = (req, res, params={}) => { - const deviceProps = { requestId: req.body.requestId, payload: { agentUserId: params.uid, devices: [] } }; - const payload_data = (deviceId, deviceNickname, deviceData) => { - const deviceType = deviceData.type; - const deviceManufacturer = deviceData.manufacturer; - const deviceModel = deviceData.model; - const devicewillReportState = deviceData.willReportState; - const deviceTraits = deviceData.traits; - const deviceAttributes = deviceData.attributes; - let deviceName = deviceData.name; - if(!deviceName) { - if(deviceManufacturer && deviceModel) { - deviceName = new String('' + deviceManufacturer + ' ' + deviceModel); - } else { - deviceName = deviceData.id; - } - } - const device_payload = { - id: deviceId, - type: deviceData.type, - name: { - defaultNames: [ deviceName ], - name: deviceName, - nicknames: [ deviceNickname ] - }, - deviceInfo: {}, - traits: [], - willReportState: devicewillReportState - }; - if(deviceManufacturer) { - device_payload['deviceInfo']['manufacturer'] = deviceManufacturer; - } - if(deviceModel) { - device_payload['deviceInfo']['model'] = deviceModel; - } - if(deviceTraits) { - device_payload['traits'] = deviceTraits; - } - if(deviceAttributes) { - device_payload['attributes'] = deviceAttributes; - } - return device_payload; - }; - - Promise.all([ - Model.getGroupDevicesByUserId(params.uid), - Model.getUserDevices(params.uid) - ]).then((deviceData) => { - valuniq(flatten(deviceData)).forEach((data) => { - deviceProps['payload']['devices'].push(payload_data(data.id, data.name, data.data)); - }); - console.log('sync:', JSON.stringify(deviceProps)); - res.status(200).json(deviceProps); - return; - }).catch((error) => { - showError(res, error); - return; - }); -}; - -const query = (req, res, params={}) => { - const deviceStates = { requestId: req.body.requestId, payload: { devices: {} } }; - - const reqStates = req.body.inputs[0].payload.devices; - const resPromises = []; - reqStates.forEach((reqDevice) => { - resPromises.push(Model.getStates(reqDevice.id)); - }); - const payload_devices = {}; - Promise.all(resPromises).then((states) => { - deviceStates['payload']['devices'] = states.reduce((o,c) => Object.assign(o, {[c.id]: c.data}), {}); - console.log('query:', JSON.stringify(deviceStates)); - res.status(200).json(deviceStates); - return; - }).catch((error) => { - showError(res, error); - return; - }); -}; - -const execute = (req, res, params={}) => { - const respCommands = []; - const reqCommands = req.body.inputs[0].payload.commands - - const resPromises = []; - reqCommands.forEach((curCommand) => { - curCommand.devices.forEach((curDevice) => { - let deviceId = new String(curDevice.id); - let commandsData = []; - curCommand.execution.forEach((curExec) => { - commandsData.push({ command : curExec.command, params: curExec.params }); - }); - resPromises.push(Model.setCommands(deviceId, commandsData)); - respCommands.push({ ids: [ deviceId ], status: "SUCCESS"}); - }); - }); - const resBody = { - requestId: req.body.requestId, - payload: { - commands: respCommands - } - }; - Promise.all(resPromises).then(() => { - console.log('execute:', JSON.stringify(resBody)); - res.status(200).json(resBody); - return; - }).catch((error) => { - showError(res, error); - return; - }); -}; - -exports.requestsync = (req, res) => { +exports.requestsync = async (req, res) => { console.info('/request-sync query', req.query); console.info('/request-sync body', req.body); const oauth = new OAuth2Server({ model: require("./oauth2").AuthModel }); const request = new OAuth2Server.Request(req); - const response = new OAuth2Server.Response(req); + const response = new OAuth2Server.Response(res); const apiKey = functions.config().firebase.apiKey; const options = { method: 'POST', @@ -270,25 +22,24 @@ exports.requestsync = (req, res) => { 'Content-Type': 'application/json', } }; - oauth.authenticate(request, response).then((auth) => { + try { + const auth = await oauth.authenticate(request, response); console.info('Auth OK', auth); - options.body = JSON.stringify({ agentUserId : auth.user.id }); - return fetch(requestSyncEndpoint + apiKey, options).then((res) => { - console.log("request-sync:", res.status, res.statusText); - res.status(200).json({ status: 'OK' }); - return; - }).catch(console.error); - }).catch((error) => { - showError(res, error); - return; - }); + options.body = JSON.stringify({ agentUserId: auth.user.id }); + const fetchRes = await fetch(requestSyncEndpoint + apiKey, options); + console.log("request-sync:", fetchRes.status, fetchRes.statusText); + res.status(200).json({ status: 'OK' }); + } catch (error) { + showError(res, error.message || error); + } }; -exports.reportstate = (req, res) => { + +exports.reportstate = async (req, res) => { console.info('/report-state query', req.query); console.info('/report-state body', req.body); const oauth = new OAuth2Server({ model: require("./oauth2").AuthModel }); const request = new OAuth2Server.Request(req); - const response = new OAuth2Server.Response(req); + const response = new OAuth2Server.Response(res); const apiKey = functions.config().firebase.apiKey; const options = { method: 'POST', @@ -296,9 +47,10 @@ exports.reportstate = (req, res) => { 'Content-Type': 'application/json', } }; - oauth.authenticate(request, response).then((auth) => { + try { + const auth = await oauth.authenticate(request, response); console.info('Auth OK', auth); - if(!req.body.devices) { + if (!req.body.devices) { throw new SyntaxError('devices required'); } options.body = JSON.stringify({ @@ -306,34 +58,24 @@ exports.reportstate = (req, res) => { requestId: 'request-' + Math.random(), devices: req.body.devices }); - return fetch(reportStateEndpoint + apiKey, options).then((res) => { - console.log('report-state:', res.status, res.statusText); - res.status(200).json({ status: 'OK' }); - return; - }).catch(console.error); - }).catch((error) => { - showError(res, error); - return; - }); -}; -const showError = (res, message) => { - console.error(message); - res.status(400).set({ - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - }).json({error: message}); - return; + const fetchRes = await fetch(reportStateEndpoint + apiKey, options); + console.log('report-state:', fetchRes.status, fetchRes.statusText); + res.status(200).json({ status: 'OK' }); + } catch (error) { + showError(res, error.message || error); + } }; -exports.root = (req, res) => { +exports.root = async (req, res) => { console.info('/ query', JSON.stringify(req.query)); console.info('/ body', JSON.stringify(req.body)); const oauth = new OAuth2Server({ model: require("./oauth2").AuthModel }); const request = new OAuth2Server.Request(req); - const response = new OAuth2Server.Response(req); - oauth.authenticate(request, response).then((auth) => { + const response = new OAuth2Server.Response(res); + try { + const auth = await oauth.authenticate(request, response); console.info('Auth OK', auth); - if (! req.body.inputs) { + if (!req.body.inputs) { return showError(res, "missing inputs"); } const params = { @@ -343,22 +85,20 @@ exports.root = (req, res) => { for (let i = 0; i < req.body.inputs.length; i++) { switch (req.body.inputs[i].intent) { case "action.devices.SYNC": - sync(req, res, params); + await sync(req, res, params); return; case "action.devices.QUERY": - query(req, res, params); + await query(req, res, params); return; case "action.devices.EXECUTE": - execute(req, res, params); + await execute(req, res, params); return; default: showError(res, "missing intent"); return; } } - return; - }).catch((error) => { - showError(res, error); - return; - }); + } catch (error) { + showError(res, error.message || error); + } }; diff --git a/functions/eslint.config.js b/functions/eslint.config.js new file mode 100644 index 0000000..2431b3c --- /dev/null +++ b/functions/eslint.config.js @@ -0,0 +1,19 @@ +const js = require("@eslint/js"); +const globals = require("globals"); + +module.exports = [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: "commonjs", + globals: { + ...globals.node, + }, + }, + rules: { + "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "no-undef": "error", + }, + }, +]; diff --git a/functions/index.js b/functions/index.js index 524bc61..41822a7 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,31 +1,17 @@ 'use strict'; -const functions = require('firebase-functions'); +const functions = require('firebase-functions/v1'); const admin = require("firebase-admin"); -admin.initializeApp(); -const app = require('./app'); -const oauth = require('./oauth2'); - -const express = require('express')(); -const bodyParser = require('body-parser'); -express.use(require("morgan")('dev')); -express.use(bodyParser.json()); -express.use(bodyParser.urlencoded({extended: true})); -express.set('trust proxy', 1); -express.use(require('cookie-parser')()); -express.set("view engine", "ejs"); -express.engine("ejs", require("ejs").__express); +if (admin.apps.length === 0) { + admin.initializeApp(); +} -express.all('/', app.root); -express.all('/request-sync', app.requestsync); -express.all('/report-state', app.reportstate); -express.all('/token', oauth.token); -express.all('/auth', oauth.auth); +const server = require('./server'); -exports.authAction = functions.auth.user().onCreate((userRecord, context) => { +exports.authAction = functions.auth.user().onCreate((userRecord, _context) => { const uid = userRecord.uid; - const email = userRecord.email + const email = userRecord.email; console.log('user added:', uid); return admin.firestore().collection('users').doc(uid).set({ id: uid, @@ -33,17 +19,18 @@ exports.authAction = functions.auth.user().onCreate((userRecord, context) => { createdAt: (new Date()).toJSON() }); }); -exports.delAccount = functions.auth.user().onDelete((userRecord, context) => { + +exports.delAccount = functions.auth.user().onDelete((userRecord, _context) => { const uid = userRecord.uid; return admin.firestore().collection('users').doc(uid).delete(); }); function wrapCloudFunctionHandler(handler) { return (req, res) => { - if(!req.url || !req.path) req.url = '/' + (req.url || ''); + if (!req.url || !req.path) req.url = '/' + (req.url || ''); delete req.headers['x-forwarded-proto']; handler(req, res); }; } -exports.ha = functions.https.onRequest(wrapCloudFunctionHandler(express)); +exports.ha = functions.https.onRequest(wrapCloudFunctionHandler(server)); diff --git a/functions/intents.js b/functions/intents.js new file mode 100644 index 0000000..cfe8a96 --- /dev/null +++ b/functions/intents.js @@ -0,0 +1,119 @@ +'use strict'; + +const Model = require('./models'); +const { valuniq, flatten, showError } = require('./utils'); + +const payload_data = (deviceId, deviceNickname, deviceData) => { + const deviceType = deviceData.type; + const deviceManufacturer = deviceData.manufacturer; + const deviceModel = deviceData.model; + const devicewillReportState = deviceData.willReportState; + const deviceTraits = deviceData.traits; + const deviceAttributes = deviceData.attributes; + let deviceName = deviceData.name; + if (!deviceName) { + if (deviceManufacturer && deviceModel) { + deviceName = String(`${deviceManufacturer} ${deviceModel}`); + } else { + deviceName = deviceData.id; + } + } + const device_payload = { + id: deviceId, + type: deviceType, + name: { + defaultNames: [deviceName], + name: deviceName, + nicknames: [deviceNickname] + }, + deviceInfo: {}, + traits: [], + willReportState: devicewillReportState + }; + if (deviceManufacturer) { + device_payload['deviceInfo']['manufacturer'] = deviceManufacturer; + } + if (deviceModel) { + device_payload['deviceInfo']['model'] = deviceModel; + } + if (deviceTraits) { + device_payload['traits'] = deviceTraits; + } + if (deviceAttributes) { + device_payload['attributes'] = deviceAttributes; + } + return device_payload; +}; + +const sync = async (req, res, params = {}) => { + try { + const deviceProps = { requestId: req.body.requestId, payload: { agentUserId: params.uid, devices: [] } }; + const deviceData = await Promise.all([ + Model.getGroupDevicesByUserId(params.uid), + Model.getUserDevices(params.uid) + ]); + + valuniq(flatten(deviceData)).forEach((data) => { + deviceProps['payload']['devices'].push(payload_data(data.id, data.name, data.data)); + }); + + console.log('sync:', JSON.stringify(deviceProps)); + res.status(200).json(deviceProps); + } catch (error) { + showError(res, error.message || error); + } +}; + +const query = async (req, res, _params = {}) => { + try { + const deviceStates = { requestId: req.body.requestId, payload: { devices: {} } }; + const reqStates = req.body.inputs[0].payload.devices; + + const states = await Promise.all(reqStates.map(reqDevice => Model.getStates(reqDevice.id))); + + deviceStates['payload']['devices'] = states.reduce((o, c) => Object.assign(o, { [c.id]: c.data }), {}); + console.log('query:', JSON.stringify(deviceStates)); + res.status(200).json(deviceStates); + } catch (error) { + showError(res, error.message || error); + } +}; + +const execute = async (req, res, _params = {}) => { + try { + const respCommands = []; + const reqCommands = req.body.inputs[0].payload.commands; + const resPromises = []; + + reqCommands.forEach((curCommand) => { + curCommand.devices.forEach((curDevice) => { + let deviceId = String(curDevice.id); + let commandsData = []; + curCommand.execution.forEach((curExec) => { + commandsData.push({ command: curExec.command, params: curExec.params }); + }); + resPromises.push(Model.setCommands(deviceId, commandsData)); + respCommands.push({ ids: [deviceId], status: "SUCCESS" }); + }); + }); + + const resBody = { + requestId: req.body.requestId, + payload: { + commands: respCommands + } + }; + + await Promise.all(resPromises); + console.log('execute:', JSON.stringify(resBody)); + res.status(200).json(resBody); + } catch (error) { + showError(res, error.message || error); + } +}; + +module.exports = { + sync, + query, + execute +}; diff --git a/functions/models.js b/functions/models.js new file mode 100644 index 0000000..ddd4690 --- /dev/null +++ b/functions/models.js @@ -0,0 +1,141 @@ +'use strict'; + +const admin = require("firebase-admin"); + +const getFromFirestore = (collection, doc) => { + return admin.firestore().collection(collection).doc(doc); +}; + +const getFromDatabase = (refkey, pkey) => { + return admin.database().ref(refkey + '/' + pkey).once('value'); +}; + +const Model = {}; + +const getPersonalDevices = async (personal, personalId) => { + let personalId_key = undefined; + switch (personal) { + case "group_devices": + personalId_key = 'groupId'; + break; + case "user_devices": + personalId_key = 'userId'; + break; + default: + throw new SyntaxError('personal required'); + } + + const querysnap = await admin.firestore().collection(personal).where(personalId_key, '==', personalId).get(); + + const devicePromises = querysnap.docs.map(async (docsnap) => { + const docdata = docsnap.data(); + if (!docdata.deviceId && !docdata.deviceReference) { + throw new SyntaxError('require deviceId or deviceReference in group_devices.'); + } + + let data; + if (docdata.deviceReference) { + const devsnap = await docdata.deviceReference.get(); + data = devsnap.exists ? devsnap.data() : null; + } else { + const devobj = await Model.getDevice(docdata.deviceId); + data = devobj.data || null; + } + + return { + id: docsnap.id, + name: docdata.name, + data: data + }; + }); + + return Promise.all(devicePromises); +}; + +Model.getGroupDevices = (groupId) => { + return getPersonalDevices('group_devices', groupId); +}; + +Model.getUserDevices = (userId) => { + return getPersonalDevices('user_devices', userId); +}; + +Model.getDevice = async (deviceId) => { + const docsnap = await getFromFirestore('devices', deviceId).get(); + if (docsnap && docsnap.exists) { + return { + id: docsnap.id, + data: docsnap.data() + }; + } + return {}; +}; + +Model.getRemote = async (remoteId) => { + const docsnap = await getFromFirestore('remotes', remoteId).get(); + if (docsnap && docsnap.exists) { + return { + id: docsnap.id, + data: docsnap.data() + }; + } + return {}; +}; + +Model.getUser = async (userId) => { + const docsnap = await getFromFirestore('users', userId).get(); + if (docsnap && docsnap.exists) { + return { + id: docsnap.id, + data: docsnap.data() + }; + } + return {}; +}; + +Model.getGroup = async (groupId) => { + const docsnap = await getFromFirestore('groups', groupId).get(); + if (docsnap && docsnap.exists) { + return { + id: docsnap.id, + data: docsnap.data() + }; + } + return {}; +}; + +Model.getCommands = async (indivDeviceId) => { + const snap = await getFromDatabase('commands', indivDeviceId); + return { + id: indivDeviceId, + data: snap.val() + }; +}; + +Model.setCommands = (indivDeviceId, commandData) => { + return admin.database().ref('commands/' + indivDeviceId).set(commandData); +}; + +Model.getStates = async (indivDeviceId) => { + const snap = await getFromDatabase('states', indivDeviceId); + return { + id: indivDeviceId, + data: snap.val() + }; +}; + +Model.setStates = (indivDeviceId, statesData) => { + return admin.database().ref('states/' + indivDeviceId).update(statesData); +}; + +Model.getGroupDevicesByUserId = async (userId) => { + const querysnap = await admin.firestore().collection('groups').select(userId).get(); + const groupDevicePromises = []; + querysnap.forEach((docsnap) => { + if (docsnap && docsnap.exists) + groupDevicePromises.push(Model.getGroupDevices(docsnap.id)); + }); + return Promise.all(groupDevicePromises); +}; + +module.exports = Model; diff --git a/functions/oauth2.js b/functions/oauth2.js index e29a132..f3c7982 100644 --- a/functions/oauth2.js +++ b/functions/oauth2.js @@ -1,96 +1,117 @@ 'use strict'; -const functions = require('firebase-functions'); const admin = require("firebase-admin"); const OAuth2Server = require("oauth2-server"); const AuthStore = require("./oauth2_store"); const firebaseConfig = JSON.parse(process.env.FIREBASE_CONFIG); + const AuthModel = { - getAccessToken : (accessToken) => { - return AuthStore.getAccessToken(accessToken).then((token) => { - return AuthStore.getClient(token.client_id).then((client) => { - return { - accessToken: token.access_token, - accessTokenExpiresAt: new Date(token.expires_at), - client: client, - user: { id: token.user_id } - }; - }).catch(console.error); - }).catch(console.error); + getAccessToken: async (accessToken) => { + try { + const token = await AuthStore.getAccessToken(accessToken); + if (!token) return null; + const client = await AuthStore.getClient(token.client_id); + return { + accessToken: token.access_token, + accessTokenExpiresAt: new Date(token.expires_at), + client: client, + user: { id: token.user_id } + }; + } catch (error) { + console.error('getAccessToken error:', error); + throw error; + } }, - getRefreshToken : (refreshToken) => { + getRefreshToken: async (refreshToken) => { console.log('getRefreshToken:', refreshToken); - return AuthStore.getRefreshToken(refreshToken).then((token) => { - return AuthStore.getClient(token.client_id).then((client) => { - return { - refreshToken: token.refresh_token, - refreshTokenExpiresAt: new Date(token.expires_at), - client: client, - user: { id: token.user_id } - }; - }).catch(console.error); - }).catch(console.error); + try { + const token = await AuthStore.getRefreshToken(refreshToken); + if (!token) return null; + const client = await AuthStore.getClient(token.client_id); + return { + refreshToken: token.refresh_token, + refreshTokenExpiresAt: new Date(token.expires_at), + client: client, + user: { id: token.user_id } + }; + } catch (error) { + console.error('getRefreshToken error:', error); + throw error; + } }, - getAuthorizationCode : (authCode) => { + getAuthorizationCode: async (authCode) => { console.log('getAuthorizationCode:', authCode); - return AuthStore.getAuthorizationCode(authCode).then((code) => { - return AuthStore.getClient(code.client_id).then((client) => { - return { - code: code.authorization_code, - expiresAt: new Date(code.expires_at), - redirectUri: code.redirect_uri, - client: client, - user: { id: code.user_id } - }; - }).catch(console.error); - }).catch(console.error); + try { + const code = await AuthStore.getAuthorizationCode(authCode); + if (!code) return null; + const client = await AuthStore.getClient(code.client_id); + return { + code: code.authorization_code, + expiresAt: new Date(code.expires_at), + redirectUri: code.redirect_uri, + client: client, + user: { id: code.user_id } + }; + } catch (error) { + console.error('getAuthorizationCode error:', error); + throw error; + } }, - getClient : (clientId, clientSecret) => { + getClient: async (clientId, clientSecret) => { console.log('getClient:', clientId, clientSecret); - return AuthStore.getClient(clientId, clientSecret).then((client) => { + try { + const client = await AuthStore.getClient(clientId, clientSecret); return { id: client.id, redirectUris: client.redirect_uris, grants: client.grants }; - }).catch(console.error); + } catch (error) { + console.error('getClient error:', error); + throw error; + } }, - saveToken : (token, client, user) => { + saveToken: async (token, client, user) => { console.log('saveToken:', token, client, user); - const access_token = AuthStore.setAccessTokens(token.accessToken, { - access_token: token.accessToken, - expires_at: new Date(token.accessTokenExpiresAt).toJSON(), - client_id: client.id, - user_id: user.id, - }); - const refresh_token = AuthStore.setRefreshToken(token.refreshToken, { - refresh_token: token.refreshToken, - expires_at: new Date(token.refreshTokenExpiresAt).toJSON(), - client_id: client.id, - user_id: user.id - }); - return access_token.then((accessToken) => { - return refresh_token.then((refreshToken) => { - return { - accessToken: accessToken.access_token, - accessTokenExpiresAt: new Date(accessToken.expires_at), - refreshToken: refreshToken.refresh_token, - refreshTokenExpiresAt: new Date(refreshToken.expires_at), - client: { id: accessToken.client_id }, - user: { id: accessToken.user_id } - }; - }).catch(console.error); - }).catch(console.error); + try { + const accessTokenPromise = AuthStore.setAccessTokens(token.accessToken, { + access_token: token.accessToken, + expires_at: new Date(token.accessTokenExpiresAt).toJSON(), + client_id: client.id, + user_id: user.id, + }); + const refreshTokenPromise = AuthStore.setRefreshToken(token.refreshToken, { + refresh_token: token.refreshToken, + expires_at: new Date(token.refreshTokenExpiresAt).toJSON(), + client_id: client.id, + user_id: user.id + }); + + const [accessToken, refreshToken] = await Promise.all([accessTokenPromise, refreshTokenPromise]); + + return { + accessToken: accessToken.access_token, + accessTokenExpiresAt: new Date(accessToken.expires_at), + refreshToken: refreshToken.refresh_token, + refreshTokenExpiresAt: new Date(refreshToken.expires_at), + client: { id: accessToken.client_id }, + user: { id: accessToken.user_id } + }; + } catch (error) { + console.error('saveToken error:', error); + throw error; + } }, - saveAuthorizationCode : (code, client, user) => { + saveAuthorizationCode: async (code, client, user) => { console.log('saveAuthorizationCode:', code, client, user); - return AuthStore.setAuthCode(code.authorizationCode, { - authorization_code: code.authorizationCode, - expires_at: new Date(code.expiresAt).toJSON(), - redirect_uri: code.redirectUri, - client_id: client.id, - user_id: user.id - }).then((authorizationCode) => { + try { + const authorizationCode = await AuthStore.setAuthCode(code.authorizationCode, { + authorization_code: code.authorizationCode, + expires_at: new Date(code.expiresAt).toJSON(), + redirect_uri: code.redirectUri, + client_id: client.id, + user_id: user.id + }); return { authorizationCode: authorizationCode.authorization_code, expiresAt: new Date(authorizationCode.expires_at), @@ -98,51 +119,58 @@ const AuthModel = { client: { id: authorizationCode.client_id }, user: { id: authorizationCode.user_id } }; - }).catch(console.error); + } catch (error) { + console.error('saveAuthorizationCode error:', error); + throw error; + } }, - revokeToken : (token) => { + revokeToken: async (token) => { console.log('revokeToken:', token); - return AuthStore.unsetRefreshToken(token.refreshToken).then((refreshToken) => { + try { + await AuthStore.unsetRefreshToken(token.refreshToken); return true; - }).catch(console.error); + } catch (error) { + console.error('revokeToken error:', error); + throw error; + } }, - revokeAuthorizationCode : (code) => { + revokeAuthorizationCode: async (code) => { console.log('revokeAuthorizationCode:', code); - return AuthStore.unsetAuthCode(code.code).then((authorizationCode) => { + try { + await AuthStore.unsetAuthCode(code.code); return true; - }).catch(console.error); + } catch (error) { + console.error('revokeAuthorizationCode error:', error); + throw error; + } } }; + const oauth = new OAuth2Server({ model: AuthModel }); -exports.auth = (req, res, next) => { +exports.auth = async (req, res, _next) => { console.info('/auth query', req.query); console.info('/auth body', req.body); - const client_id = (req.query.client_id || req.body.client_id); const redirect_uri = (req.query.redirect_uri || req.body.redirect_uri); const state = (req.query.state || req.body.state); const bearer = req.headers.authorization; - if(bearer && bearer.startsWith('Bearer ')) { + + if (bearer && bearer.startsWith('Bearer ')) { const idToken = bearer.split('Bearer ')[1]; delete req.headers.authorization; - return admin.auth().verifyIdToken(idToken).then((decodedIdToken) => { - const options = { authenticateHandler: { handle: (data) => { return { id: decodedIdToken.sub } } } }; + try { + const decodedIdToken = await admin.auth().verifyIdToken(idToken); + const options = { authenticateHandler: { handle: () => ({ id: decodedIdToken.sub }) } }; const request = new OAuth2Server.Request(req); const response = new OAuth2Server.Response(res); - oauth.authorize(request, response, options).then((authcode) => { - res.status(200).send(authcode.authorizationCode); - return; - }).catch((error) => { - console.error(error); - res.status(401).json({ error: "invalid_request", description: error }); - return; - }); + const authcode = await oauth.authorize(request, response, options); + res.status(200).send(authcode.authorizationCode); return; - }).catch((error) => { - console.error('Error while verifying Firebase ID token:', error); - res.status(401).json({ error: "invalid_request", description: 'Unauthorized' }); + } catch (error) { + console.error('Auth verify error:', error); + res.status(401).json({ error: "invalid_request", description: error.message || error }); return; - }); + } } return res.render('login', { config: firebaseConfig, @@ -150,24 +178,25 @@ exports.auth = (req, res, next) => { state: state }); }; -exports.token = (req, res, next) => { + +exports.token = async (req, res, _next) => { console.info('/token query', req.query); console.info('/token body', req.body); const request = new OAuth2Server.Request(req); const response = new OAuth2Server.Response(res); // refresh token will expire in 10 years. const options = { refreshTokenLifetime: 86400 * 3650 }; - oauth.token(request, response, options).then((token) => { + try { + await oauth.token(request, response, options); res.set(response.headers); res.status(response.status).send(response.body); - return; - }).catch((error) =>{ - console.error(error); - if(response) { + } catch (error) { + console.error('Token error:', error); + if (response) { res.set(response.headers); } - res.status(400).send({ error: "invalid_request", description: error }); - return; - }); + res.status(400).send({ error: "invalid_request", description: error.message || error }); + } }; + exports.AuthModel = AuthModel; diff --git a/functions/oauth2_store.js b/functions/oauth2_store.js index 3f799d9..1b761af 100644 --- a/functions/oauth2_store.js +++ b/functions/oauth2_store.js @@ -1,6 +1,7 @@ 'use strict'; -const functions = require('firebase-functions'); +const functions = require('firebase-functions/v1'); const admin = require("firebase-admin"); + const app_clientId = functions.config().app.id; const app_clientSecret = functions.config().app.key; const app_projectId = JSON.parse(process.env.FIREBASE_CONFIG).projectId; @@ -8,88 +9,86 @@ const app_redirectUris = [ 'https://oauth-redirect.googleusercontent.com/r/' + app_projectId, 'https://' + app_projectId + '.firebaseapp.com/__/auth/handler' ]; -const app_grants = [ 'authorization_code', 'refresh_token' ]; +const app_grants = ['authorization_code', 'refresh_token']; -const setToDatabase = (keytype, key, value={}) => { +const setToDatabase = async (keytype, key, value = {}) => { console.log('%s/%s set to database', keytype, key, JSON.stringify(value)); - return new Promise((resolve, reject) => { - if(!keytype) { - console.error('KeyType required'); - reject('KeyType required'); - return; - } - if(!key) { - console.error('Key required'); - reject('Key required'); - return; - } - admin.database().ref(keytype + '/' + key).set(value).catch((error) => { - console.error(error); - reject(error); - return; - }); - resolve(value); - }).catch(console.error); + if (!keytype) { + throw new Error('KeyType required'); + } + if (!key) { + throw new Error('Key required'); + } + try { + await admin.database().ref(`${keytype}/${key}`).set(value); + return value; + } catch (error) { + console.error(error); + throw error; + } }; -const getFromDatabase = (keytype, key) => { + +const getFromDatabase = async (keytype, key) => { console.log('%s/%s get from database', keytype, key); - return new Promise((resolve, reject) => { - if(!keytype) { - console.error('KeyType required'); - reject('KeyType required'); - return; - } - if(!key) { - console.error('Key required'); - reject('Key required'); - return; - } - resolve(admin.database().ref(keytype + '/' + key).once('value').then((snapshot) => { - return snapshot.val(); - }).catch(console.error)); - }).catch(console.error); + if (!keytype) { + throw new Error('KeyType required'); + } + if (!key) { + throw new Error('Key required'); + } + try { + const snapshot = await admin.database().ref(`${keytype}/${key}`).once('value'); + return snapshot.val(); + } catch (error) { + console.error(error); + throw error; + } }; exports.getAccessToken = (key) => { return getFromDatabase('accesstoken', key); }; -exports.getClient = (client_id, client_secret) => { - return new Promise((resolve, reject) => { - if(client_id !== app_clientId) { - console.error('mismatch client id', client_id, app_clientId); - reject('mismatch client id'); - return; - } - if(client_secret && client_secret !== app_clientSecret) { - console.error('mismatch client secret', client_secret, app_clientSecret); - reject('mismatch client secret'); - return; - } - resolve({ - id: app_clientId, - redirect_uris: app_redirectUris, - grants: app_grants - }); - }).catch(console.error); + +exports.getClient = async (client_id, client_secret) => { + if (client_id !== app_clientId) { + console.error('mismatch client id', client_id, app_clientId); + throw new Error('mismatch client id'); + } + if (client_secret && client_secret !== app_clientSecret) { + console.error('mismatch client secret', client_secret, app_clientSecret); + throw new Error('mismatch client secret'); + } + return { + id: app_clientId, + redirect_uris: app_redirectUris, + grants: app_grants + }; }; + exports.getRefreshToken = (key) => { return getFromDatabase('refreshtoken', key); }; + exports.getAuthorizationCode = (key) => { return getFromDatabase('authcode', key); }; -exports.setAccessTokens = (key, value={}) => { + +exports.setAccessTokens = (key, value = {}) => { return setToDatabase('accesstoken', key, value); }; -exports.setRefreshToken = (key, value={}) => { + +exports.setRefreshToken = (key, value = {}) => { return setToDatabase('refreshtoken', key, value); }; -exports.setAuthCode = (key, value={}) => { + +exports.setAuthCode = (key, value = {}) => { return setToDatabase('authcode', key, value); }; + exports.unsetRefreshToken = (key) => { return setToDatabase('refreshtoken', key, null); }; + exports.unsetAuthCode = (key) => { return setToDatabase('authcode', key, null); }; diff --git a/functions/package.json b/functions/package.json index 170df1a..f11c293 100644 --- a/functions/package.json +++ b/functions/package.json @@ -6,19 +6,20 @@ "private": true, "license": "MIT", "dependencies": { + "@snyk/protect": "latest", "body-parser": "latest", "cookie-parser": "latest", "ejs": "latest", "eslint": "^9.0.0", "express": "latest", + "express-rate-limit": "^8.2.1", "firebase-admin": "latest", "firebase-functions": "latest", "firebaseui": "^3.0.0", + "morgan": "latest", "node-fetch": "latest", "oauth2-server": "latest", - "morgan": "latest", - "util": "latest", - "@snyk/protect": "latest" + "util": "latest" }, "scripts": { "lint": "eslint", diff --git a/functions/server.js b/functions/server.js new file mode 100644 index 0000000..d8feffa --- /dev/null +++ b/functions/server.js @@ -0,0 +1,37 @@ +'use strict'; + +const express = require('express'); +const bodyParser = require('body-parser'); +const morgan = require('morgan'); +const cookieParser = require('cookie-parser'); +const ejs = require('ejs'); +const rateLimit = require('express-rate-limit'); + +const app = require('./app'); +const oauth = require('./oauth2'); + +const server = express(); + +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + limit: 100, // Limit each IP to 100 requests per `window` + standardHeaders: 'draft-8', // combined `RateLimit` header + legacyHeaders: false, // Disable the `X-RateLimit-*` headers +}); + +server.use(morgan('dev')); +server.use(limiter); +server.use(bodyParser.json()); +server.use(bodyParser.urlencoded({ extended: true })); +server.set('trust proxy', 1); +server.use(cookieParser()); +server.set("view engine", "ejs"); +server.engine("ejs", ejs.__express); + +server.all('/', app.root); +server.all('/request-sync', app.requestsync); +server.all('/report-state', app.reportstate); +server.all('/token', oauth.token); +server.all('/auth', oauth.auth); + +module.exports = server; diff --git a/functions/utils.js b/functions/utils.js new file mode 100644 index 0000000..0671a60 --- /dev/null +++ b/functions/utils.js @@ -0,0 +1,30 @@ +'use strict'; + +const valuniq = (obj) => { + const arrobj = {}; + for (let i = 0; i < obj.length; i++) { + arrobj[obj[i]['id']] = obj[i]; + } + const newret = []; + for (let key in arrobj) { + newret.push(arrobj[key]); + } + return newret; +}; + +const flatten = (arr) => Array.isArray(arr) ? [].concat.apply([], arr.map(flatten)) : arr; + +const showError = (res, message) => { + console.error(message); + res.status(400).set({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }).json({ error: message }); + return; +}; + +module.exports = { + valuniq, + flatten, + showError +};