diff --git a/src/api-binder/index.ts b/src/api-binder/index.ts index 352082a2..566644a6 100644 --- a/src/api-binder/index.ts +++ b/src/api-binder/index.ts @@ -572,8 +572,6 @@ export const initialized = _.once(async () => { }); export const router = express.Router(); -router.use(express.urlencoded({ limit: '10mb', extended: true })); -router.use(express.json({ limit: '10mb' })); router.post('/v1/update', (req, res, next) => { eventTracker.track('Update notification'); diff --git a/src/compose/application-manager.ts b/src/compose/application-manager.ts index 5746bda9..5b726670 100644 --- a/src/compose/application-manager.ts +++ b/src/compose/application-manager.ts @@ -61,8 +61,6 @@ const localModeManager = new LocalModeManager(); export const router = (() => { const $router = express.Router(); - $router.use(express.urlencoded({ extended: true, limit: '10mb' })); - $router.use(express.json({ limit: '10mb' })); createV1Api($router); createV2Api($router); diff --git a/src/compose/utils.ts b/src/compose/utils.ts index f8e968c2..5a3d87b3 100644 --- a/src/compose/utils.ts +++ b/src/compose/utils.ts @@ -20,7 +20,7 @@ import { import log from '../lib/supervisor-console'; -import * as apiKeys from '../lib/api-keys'; +import * as apiKeys from '../device-api/api-keys'; export function camelCaseConfig( literalConfig: ConfigMap, diff --git a/src/lib/api-keys.ts b/src/device-api/api-keys.ts similarity index 68% rename from src/lib/api-keys.ts rename to src/device-api/api-keys.ts index cc309ef7..edd7bdbe 100644 --- a/src/lib/api-keys.ts +++ b/src/device-api/api-keys.ts @@ -1,13 +1,13 @@ import * as _ from 'lodash'; import * as express from 'express'; import * as memoizee from 'memoizee'; +import { TypedError } from 'typed-error'; -import * as config from '../config'; import * as db from '../db'; -import { generateUniqueKey } from './register-device'; +import { generateUniqueKey } from '../lib/register-device'; -export class KeyNotFoundError extends Error {} +class KeyNotFoundError extends TypedError {} /** * The schema for the `apiSecret` table in the database @@ -20,7 +20,7 @@ interface DbApiSecret { key: string; } -export type Scope = SerializableScope; +type Scope = SerializableScope; type ScopeTypeKey = keyof ScopeTypes; type SerializableScope = { type: T; @@ -59,13 +59,8 @@ const scopeChecks: ScopeCheckCollection = { resources.apps != null && resources.apps.includes(appId), }; -export function serialiseScopes(scopes: Scope[]): string { - return JSON.stringify(scopes); -} - -export function deserialiseScopes(json: string): Scope[] { - return JSON.parse(json); -} +const serialiseScopes = (scopes: Scope[]): string => JSON.stringify(scopes); +const deserialiseScopes = (json: string): Scope[] => JSON.parse(json); export const isScoped = ( resources: Partial, @@ -99,95 +94,9 @@ export const initialized = _.once(async () => { await generateCloudKey(); }); -/** - * This middleware will extract an API key used to make a call, and then expand it out to provide - * access to the scopes it has. The `req` will be updated to include this `auth` data. - * - * E.g. `req.auth.scopes: []` - * - * @param req - * @param res - * @param next - */ -export const authMiddleware: AuthorizedRequestHandler = async ( - req, - res, - next, -) => { - // grab the API key used for the request - const apiKey = getApiKeyFromRequest(req) ?? ''; +const isEqualScope = (a: Scope, b: Scope): boolean => _.isEqual(a, b); - // store the key in the request, and an empty scopes array to populate after resolving the key scopes - req.auth = { - apiKey, - scopes: [], - isScoped: (resources) => isScoped(resources, req.auth.scopes), - }; - - try { - const conf = await config.getMany(['localMode', 'unmanaged']); - - // we only need to check the API key if managed and not in local mode - const needsAuth = !conf.unmanaged && !conf.localMode; - - // no need to authenticate, shortcut - if (!needsAuth) { - // Allow requests that do not need auth to be scoped for all applications - req.auth.isScoped = () => true; - return next(); - } - - // if we have a key, find the scopes and add them to the request - if (apiKey && apiKey !== '') { - await initialized(); - const scopes = await getScopesForKey(apiKey); - - if (scopes != null) { - // keep the scopes for later incase they're desired - req.auth.scopes.push(...scopes); - return next(); - } - } - - // we do not have a valid key... - return res.sendStatus(401); - } catch (err) { - console.error(err); - res.status(503).send(`Unexpected error: ${err}`); - } -}; - -function isEqualScope(a: Scope, b: Scope): boolean { - return _.isEqual(a, b); -} - -function getApiKeyFromRequest(req: express.Request): string | undefined { - // Check query for key - if (req.query.apikey) { - if (typeof req.query.apikey !== 'string') { - // We were passed something as an api key but it wasn't a string - // so ignore it - return; - } - return req.query.apikey; - } - - // Get Authorization header to search for key - const authHeader = req.get('Authorization'); - - // Check header for key - if (!authHeader) { - return; - } - - // Check authHeader with various schemes - const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); - - // Return key from match or undefined - return match?.[1]; -} - -export type GenerateKeyOptions = { force: boolean; scopes: Scope[] }; +type GenerateKeyOptions = { force: boolean; scopes: Scope[] }; export async function getScopesForKey(key: string): Promise { const apiKey = await getApiKeyByKey(key); diff --git a/src/device-api/index.ts b/src/device-api/index.ts index 4aaa138d..f73d3591 100644 --- a/src/device-api/index.ts +++ b/src/device-api/index.ts @@ -1,31 +1,14 @@ import * as express from 'express'; import * as _ from 'lodash'; -import * as morgan from 'morgan'; +import * as middleware from './middleware'; +import * as apiKeys from './api-keys'; import * as eventTracker from '../event-tracker'; import * as deviceState from '../device-state'; import blink = require('../lib/blink'); import log from '../lib/supervisor-console'; -import * as apiKeys from '../lib/api-keys'; -import { UpdatesLockedError } from '../lib/errors'; import type { Server } from 'http'; -import type { NextFunction, Request, Response } from 'express'; - -const expressLogger = morgan( - (tokens, req, res) => - [ - tokens.method(req, res), - req.path, - tokens.status(req, res), - '-', - tokens['response-time'](req, res), - 'ms', - ].join(' '), - { - stream: { write: (d) => log.api(d.toString().trimRight()) }, - }, -); interface SupervisorAPIConstructOpts { routers: express.Router[]; @@ -48,7 +31,7 @@ export class SupervisorAPI { this.healthchecks = healthchecks; this.api.disable('x-powered-by'); - this.api.use(expressLogger); + this.api.use(middleware.logging); this.api.get('/v1/healthy', async (_req, res) => { try { @@ -66,7 +49,7 @@ export class SupervisorAPI { this.api.get('/ping', (_req, res) => res.send('OK')); - this.api.use(apiKeys.authMiddleware); + this.api.use(middleware.auth); this.api.post('/v1/blink', (_req, res) => { eventTracker.track('Device blink'); @@ -102,44 +85,15 @@ export class SupervisorAPI { }, ); + this.api.use(express.urlencoded({ limit: '10mb', extended: true })); + this.api.use(express.json({ limit: '10mb' })); + // And assign all external routers for (const router of this.routers) { this.api.use(router); } - // Error handling. - const messageFromError = (err?: Error | string | null): string => { - let message = 'Unknown error'; - if (err != null) { - if (_.isError(err) && err.message != null) { - message = err.message; - } else { - message = err as string; - } - } - return message; - }; - - this.api.use( - (err: Error, req: Request, res: Response, next: NextFunction) => { - if (res.headersSent) { - // Error happens while we are writing the response - default handler closes the connection. - next(err); - return; - } - - // Return 423 Locked when locks as set - const code = err instanceof UpdatesLockedError ? 423 : 503; - if (code !== 423) { - log.error(`Error on ${req.method} ${req.path}: `, err); - } - - res.status(code).send({ - status: 'failed', - message: messageFromError(err), - }); - }, - ); + this.api.use(middleware.errors); } public async listen(port: number, apiTimeout: number): Promise { diff --git a/src/device-api/middleware/auth.ts b/src/device-api/middleware/auth.ts new file mode 100644 index 00000000..2d660eff --- /dev/null +++ b/src/device-api/middleware/auth.ts @@ -0,0 +1,84 @@ +import * as apiKeys from '../api-keys'; +import * as config from '../../config'; + +import type { Request } from 'express'; + +/** + * This middleware will extract an API key used to make a call, and then expand it out to provide + * access to the scopes it has. The `req` will be updated to include this `auth` data. + * + * E.g. `req.auth.scopes: []` + * + * @param req + * @param res + * @param next + */ +export const auth: apiKeys.AuthorizedRequestHandler = async ( + req, + res, + next, +) => { + // grab the API key used for the request + const apiKey = getApiKeyFromRequest(req) ?? ''; + + // store the key in the request, and an empty scopes array to populate after resolving the key scopes + req.auth = { + apiKey, + scopes: [], + isScoped: (resources) => apiKeys.isScoped(resources, req.auth.scopes), + }; + + try { + const conf = await config.getMany(['localMode', 'unmanaged']); + + // we only need to check the API key if managed and not in local mode + const needsAuth = !conf.unmanaged && !conf.localMode; + + // no need to authenticate, shortcut + if (!needsAuth) { + // Allow requests that do not need auth to be scoped for all applications + req.auth.isScoped = () => true; + return next(); + } + + // if we have a key, find the scopes and add them to the request + if (apiKey && apiKey !== '') { + await apiKeys.initialized(); + const scopes = await apiKeys.getScopesForKey(apiKey); + + if (scopes != null) { + // keep the scopes for later incase they're desired + req.auth.scopes.push(...scopes); + return next(); + } + } + + // we do not have a valid key... + return res.sendStatus(401); + } catch (err) { + console.error(err); + res.status(503).send(`Unexpected error: ${err}`); + } +}; + +function getApiKeyFromRequest(req: Request): string | undefined { + const { apikey } = req.query; + // Check query for key + if (apikey && typeof apikey === 'string') { + return apikey; + } + + // Get Authorization header to search for key + const authHeader = req.get('Authorization'); + + // Check header for key + if (!authHeader) { + return undefined; + } + + // Check authHeader with various schemes + const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i); + + // Return key from match or undefined + return match?.[1]; +} diff --git a/src/device-api/middleware/errors.ts b/src/device-api/middleware/errors.ts new file mode 100644 index 00000000..db68349f --- /dev/null +++ b/src/device-api/middleware/errors.ts @@ -0,0 +1,42 @@ +import * as _ from 'lodash'; + +import { UpdatesLockedError } from '../../lib/errors'; +import log from '../../lib/supervisor-console'; + +import type { Request, Response, NextFunction } from 'express'; + +const messageFromError = (err?: Error | string | null): string => { + let message = 'Unknown error'; + if (err != null) { + if (_.isError(err) && err.message != null) { + message = err.message; + } else { + message = err as string; + } + } + return message; +}; + +export const errors = ( + err: Error, + req: Request, + res: Response, + next: NextFunction, +) => { + if (res.headersSent) { + // Error happens while we are writing the response - default handler closes the connection. + next(err); + return; + } + + // Return 423 Locked when locks as set + const code = err instanceof UpdatesLockedError ? 423 : 503; + if (code !== 423) { + log.error(`Error on ${req.method} ${req.path}: `, err); + } + + res.status(code).send({ + status: 'failed', + message: messageFromError(err), + }); +}; diff --git a/src/device-api/middleware/index.ts b/src/device-api/middleware/index.ts new file mode 100644 index 00000000..4bdf997a --- /dev/null +++ b/src/device-api/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './logging'; +export * from './auth'; +export * from './errors'; diff --git a/src/device-api/middleware/logging.ts b/src/device-api/middleware/logging.ts new file mode 100644 index 00000000..7b0e9861 --- /dev/null +++ b/src/device-api/middleware/logging.ts @@ -0,0 +1,19 @@ +import * as morgan from 'morgan'; +import { Request } from 'express'; + +import log from '../../lib/supervisor-console'; + +export const logging = morgan( + (tokens, req: Request, res) => + [ + tokens.method(req, res), + req.path, + tokens.status(req, res), + '-', + tokens['response-time'](req, res), + 'ms', + ].join(' '), + { + stream: { write: (d) => log.api(d.toString().trimEnd()) }, + }, +); diff --git a/src/device-api/v1.ts b/src/device-api/v1.ts index c8f0255a..4d13809b 100644 --- a/src/device-api/v1.ts +++ b/src/device-api/v1.ts @@ -9,7 +9,7 @@ import { doRestart, doPurge } from './common'; import * as applicationManager from '../compose/application-manager'; import { generateStep } from '../compose/composition-steps'; import * as commitStore from '../compose/commit'; -import { AuthorizedRequest } from '../lib/api-keys'; +import { AuthorizedRequest } from './api-keys'; import { getApp } from '../device-state/db-format'; export function createV1Api(router: express.Router) { diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index fe30541e..de418cf9 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -31,7 +31,7 @@ import supervisorVersion = require('../lib/supervisor-version'); import { checkInt, checkTruthy } from '../lib/validation'; import { isVPNActive } from '../network'; import { doPurge, doRestart, safeStateClone } from './common'; -import { AuthorizedRequest } from '../lib/api-keys'; +import { AuthorizedRequest } from './api-keys'; import { fromV2TargetState } from '../lib/legacy'; export function createV2Api(router: Router) { diff --git a/src/device-state.ts b/src/device-state.ts index 1ef3292b..db20918d 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -45,7 +45,7 @@ import { AppState, } from './types'; import * as dbFormat from './device-state/db-format'; -import * as apiKeys from './lib/api-keys'; +import * as apiKeys from './device-api/api-keys'; import * as sysInfo from './lib/system-info'; const disallowedHostConfigPatchFields = ['local_ip', 'local_port']; @@ -65,8 +65,6 @@ function parseTargetState(state: unknown): TargetState { // device api stuff in ./device-api function createDeviceStateRouter() { router = express.Router(); - router.use(express.urlencoded({ limit: '10mb', extended: true })); - router.use(express.json({ limit: '10mb' })); const rebootOrShutdown = async ( req: express.Request, diff --git a/src/proxyvisor.js b/src/proxyvisor.js index e3ec168f..03e6e54d 100644 --- a/src/proxyvisor.js +++ b/src/proxyvisor.js @@ -89,8 +89,6 @@ const formatCurrentAsState = (device) => ({ const createProxyvisorRouter = function (proxyvisor) { const router = express.Router(); - router.use(express.urlencoded({ limit: '10mb', extended: true })); - router.use(express.json({ limit: '10mb' })); router.get('/v1/devices', async (_req, res) => { try { const fields = await db.models('dependentDevice').select(); diff --git a/test/integration/compose/service.spec.ts b/test/integration/compose/service.spec.ts index eb5ee1be..bd71fb4a 100644 --- a/test/integration/compose/service.spec.ts +++ b/test/integration/compose/service.spec.ts @@ -3,7 +3,7 @@ import * as _ from 'lodash'; import { expect } from 'chai'; import Service from '~/src/compose/service'; -import * as apiKeys from '~/lib/api-keys'; +import * as apiKeys from '~/src/device-api/api-keys'; describe('compose/service: integration tests', () => { describe('Feature labels', () => { diff --git a/test/legacy/21-supervisor-api.spec.ts b/test/legacy/21-supervisor-api.spec.ts index 37450db5..deab0f96 100644 --- a/test/legacy/21-supervisor-api.spec.ts +++ b/test/legacy/21-supervisor-api.spec.ts @@ -7,9 +7,9 @@ import * as apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; import Log from '~/lib/supervisor-console'; import SupervisorAPI from '~/src/device-api'; -import * as apiKeys from '~/lib/api-keys'; +import * as apiKeys from '~/src/device-api/api-keys'; import * as db from '~/src/db'; -import { cloudApiKey } from '~/lib/api-keys'; +import { cloudApiKey } from '~/src/device-api/api-keys'; const mockedOptions = { listenPort: 54321, diff --git a/test/legacy/41-device-api-v1.spec.ts b/test/legacy/41-device-api-v1.spec.ts index 0bc1ce78..95e1c4b9 100644 --- a/test/legacy/41-device-api-v1.spec.ts +++ b/test/legacy/41-device-api-v1.spec.ts @@ -23,7 +23,7 @@ import * as logger from '~/src/logger'; import SupervisorAPI from '~/src/device-api'; import * as apiBinder from '~/src/api-binder'; import * as deviceState from '~/src/device-state'; -import * as apiKeys from '~/lib/api-keys'; +import * as apiKeys from '~/src/device-api/api-keys'; import * as dbus from '~/lib/dbus'; import * as updateLock from '~/lib/update-lock'; import * as TargetState from '~/src/device-state/target-state'; diff --git a/test/legacy/42-device-api-v2.spec.ts b/test/legacy/42-device-api-v2.spec.ts index 96905dde..a84b97c5 100644 --- a/test/legacy/42-device-api-v2.spec.ts +++ b/test/legacy/42-device-api-v2.spec.ts @@ -10,7 +10,7 @@ import * as deviceState from '~/src/device-state'; import SupervisorAPI from '~/src/device-api'; import * as serviceManager from '~/src/compose/service-manager'; import * as images from '~/src/compose/images'; -import * as apiKeys from '~/lib/api-keys'; +import * as apiKeys from '~/src/device-api/api-keys'; import * as config from '~/src/config'; import * as updateLock from '~/lib/update-lock'; import * as targetStateCache from '~/src/device-state/target-state-cache'; diff --git a/test/lib/mocked-balena-api.ts b/test/lib/mocked-balena-api.ts index c8286e26..b92ee95d 100644 --- a/test/lib/mocked-balena-api.ts +++ b/test/lib/mocked-balena-api.ts @@ -12,7 +12,7 @@ const api: express.Express & { } = express(); // tslint:disable-next-line -api.use(require('body-parser').json()); +api.use(express.json()); api.balenaBackend = { currentId: 1,