diff --git a/README.md b/README.md index 3600643..fc6a27d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,16 @@ To try it out, go to your favourite HLS/MPEG-DASH video player such as `https:// | `statusCode` | Replace the response for a specific segment request with a specified status code response | | `timeout` | Force a timeout for the response of a specific segment request | | `throttle` | Send back the segment at a specified speed of bytes per second | +| `token` | JWT for authentication if `JWT_SECRET` env is set | + +### Environmental Variales + +| VARIABLE | TYPE | DESCRIPTION | +| -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | string | Enables jwt authentication for all endpoints and logs requests from users, provide token with the `token` query parameter. | +| `LOAD_PARAMS_FROM_AWS_SSM` | boolean | Load manifest url params from AWS SSM, [see below](#load-manifest-url-params-from-aws-ssm-parameter-store-instead) | +| `AWS_REGION` | string | AWS region for SSM parameters, no effect if `LOAD_PARAMS_FROM_AWS_SSM` is false | +| `AWS_SSM_PARAM_KEY` | string | Key for AWS SSM params, no effect if `LOAD_PARAMS_FROM_AWS_SSM` is false | ### Stateful Mode diff --git a/package-lock.json b/package-lock.json index faaf4aa..162958f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "fastify": "^3.29.5", "fastify-cors": "^6.0.2", "jest": "^27.5.1", + "jsonwebtoken": "^9.0.0", "lodash": "^4.17.15", "nock": "^13.2.4", "node-cache": "^5.1.2", @@ -33,6 +34,7 @@ "@types/aws-lambda": "^8.10.92", "@types/clone": "^2.1.1", "@types/jest": "^27.4.0", + "@types/jsonwebtoken": "^9.0.2", "@types/node": "^17.0.18", "@types/node-fetch": "^2.5.7", "@types/stream-throttle": "^0.1.1", @@ -3314,6 +3316,15 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -4255,6 +4266,11 @@ "isarray": "^1.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5055,6 +5071,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.75", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz", @@ -9740,6 +9764,54 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "dependencies": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -15602,6 +15674,15 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/minimist": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", @@ -16286,6 +16367,11 @@ "isarray": "^1.0.0" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -16921,6 +17007,14 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "electron-to-chromium": { "version": "1.4.75", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.75.tgz", @@ -20385,6 +20479,46 @@ "through": ">=2.2.7 <3" } }, + "jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "requires": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "dependencies": { + "semver": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", diff --git a/package.json b/package.json index 620158a..1cd9de6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json", "pretty": "prettier --check --ignore-unknown .", "deploy:dev": "git tag $(git rev-parse --short HEAD)-dev && git push --tags", - "postversion": "git push && git push --tags" + "postversion": "git push && git push --tags", + "genJwt": "node dist/genJwt.js" }, "dependencies": { "@aws-sdk/client-ssm": "^3.306.0", @@ -25,6 +26,7 @@ "fastify": "^3.29.5", "fastify-cors": "^6.0.2", "jest": "^27.5.1", + "jsonwebtoken": "^9.0.0", "lodash": "^4.17.15", "nock": "^13.2.4", "node-cache": "^5.1.2", @@ -40,6 +42,7 @@ "@types/aws-lambda": "^8.10.92", "@types/clone": "^2.1.1", "@types/jest": "^27.4.0", + "@types/jsonwebtoken": "^9.0.2", "@types/node": "^17.0.18", "@types/node-fetch": "^2.5.7", "@types/stream-throttle": "^0.1.1", diff --git a/src/genJwt.ts b/src/genJwt.ts new file mode 100644 index 0000000..10925a3 --- /dev/null +++ b/src/genJwt.ts @@ -0,0 +1,10 @@ +import jwt from 'jsonwebtoken'; + +if (process.argv.length < 5) + console.error('Expected 3 arguments; '); +else { + const secret = process.argv[2]; + const email = process.argv[3]; + const company = process.argv[4]; + console.log(jwt.sign({ company, email }, secret)); +} diff --git a/src/routes.ts b/src/routes.ts index e1c9285..6fbb2c0 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,7 +3,8 @@ import segmentRoutes from './segments/routes'; import manifestRoutes from './manifests/routes'; import { generateHeartbeatResponse, - addCustomVersionHeader + addCustomVersionHeader, + authenticateToken } from './shared/utils'; import throttlingProxyRoutes from './segments/routes/throttlingProxy'; @@ -23,4 +24,5 @@ export function registerRoutes(app: FastifyInstance) { app.register(manifestRoutes, opts); app.register(throttlingProxyRoutes, opts); addCustomVersionHeader(app); + authenticateToken(app); } diff --git a/src/server.test.ts b/src/server.test.ts index 64be32d..98af681 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -7,12 +7,19 @@ import { } from './segments/constants'; describe('Chaos Stream Proxy server', () => { + const env = process.env; let app = null; beforeEach(() => { + jest.resetModules(); + process.env = { ...env }; app = fastify(); registerRoutes(app); }); + afterEach(() => { + process.env = env; + }); + it.each([HLS_PROXY_MASTER, HLS_PROXY_MEDIA, SEGMENTS_PROXY_SEGMENT])( 'route %p contains x-version header', async (route) => { @@ -24,4 +31,25 @@ describe('Chaos Stream Proxy server', () => { ); } ); + + it('requires token when running with env JWT_SECRET set', async () => { + process.env.JWT_SECRET = 'somesecret'; + const appInternal = fastify(); + registerRoutes(appInternal); + const invalidResponse = await appInternal.inject('/?token=invalid'); + expect(invalidResponse.statusCode).toEqual(401); + + const validResponse = await appInternal.inject( + '/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb21wYW55IjoidGVzdGNvbXBhbnkiLCJlbWFpbCI6InRlc3RAZW1haWwuY29tIiwiaWF0IjoxNjg2MTUzMzU5fQ.wHnzxMdoPZlzdU0GDCzEwd5lnEmq-rX2Ew0yODxqlzg' + ); + expect(validResponse.statusCode).toEqual(200); + }); + + it('ignores token when running without env JWT_SECRET set', async () => { + process.env.JWT_SECRET = undefined; + const appInternal = fastify(); + registerRoutes(appInternal); + const response = await appInternal.inject('/?token=invalid'); + expect(response.statusCode).toEqual(200); + }); }); diff --git a/src/shared/aws.utils.ts b/src/shared/aws.utils.ts index a4686b5..a9ed7cc 100644 --- a/src/shared/aws.utils.ts +++ b/src/shared/aws.utils.ts @@ -1,8 +1,13 @@ import NodeCache from 'node-cache'; import SSM from 'aws-sdk/clients/ssm'; import dotenv from 'dotenv'; +import { JwtToken } from './utils'; +import { Context } from 'aws-lambda'; +import { Jwt } from 'jsonwebtoken'; dotenv.config(); +const IS_LAMBDA = !!process.env.LAMBDA_TASK_ROOT; + const ssm = new SSM({ region: process.env.AWS_REGION ?? 'eu-central-1' }); const cache = new NodeCache({ stdTTL: 60 }); @@ -61,3 +66,72 @@ export function addSSMUrlParametersToUrl(url: string): Promise { return Promise.resolve(url); } } + +type AwsLogLevel = 'DEBUG' | 'ERROR' | 'INFO' | 'TRACE'; + +type AwsLogMessage = string | object; + +type AwsCloudwatchLog = { + msg: AwsLogMessage; + requestId: string; + level: AwsLogLevel; + user: JwtToken | undefined; + time: Date; +}; + +export interface AwsLogger { + info: ( + msg: AwsLogMessage, + context: Context, + user: JwtToken | undefined + ) => void; + debug: ( + msg: AwsLogMessage, + context: Context, + user: JwtToken | undefined + ) => void; + error: ( + msg: AwsLogMessage, + context: Context, + user: JwtToken | undefined + ) => void; + trace: ( + msg: AwsLogMessage, + context: Context, + user: JwtToken | undefined + ) => void; +} + +function awsLog( + msg: AwsLogMessage, + context: Context | undefined, + user: JwtToken | undefined, + level: AwsLogLevel +) { + const log: AwsCloudwatchLog = { + msg, + requestId: context == undefined ? undefined : context.awsRequestId, + user: + user == undefined + ? undefined + : { email: user.email, company: user.company }, + level, + time: new Date() + }; + console.log(JSON.stringify(log)); +} + +export const awsLogger: AwsLogger = { + info(msg, context, user) { + awsLog(msg, context, user, 'INFO'); + }, + debug(msg, context, user) { + awsLog(msg, context, user, 'DEBUG'); + }, + error(msg, context, user) { + awsLog(msg, context, user, 'ERROR'); + }, + trace(msg, context, user) { + awsLog(msg, context, user, 'TRACE'); + } +}; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 1a54dae..f93876a 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -2,7 +2,12 @@ import { Response } from 'node-fetch'; import m3u8 from '@eyevinn/m3u8'; import { M3U, ServiceError } from './types'; import clone from 'clone'; -import { ALBEvent, ALBResult, ALBEventQueryStringParameters } from 'aws-lambda'; +import { + ALBEvent, + ALBResult, + ALBEventQueryStringParameters, + Context +} from 'aws-lambda'; import { ReadStream } from 'fs'; import { IncomingHttpHeaders } from 'http'; import path from 'path'; @@ -13,13 +18,14 @@ import { RequestPayload, FastifyInstance } from 'fastify'; -import { addSSMUrlParametersToUrl } from './aws.utils'; +import { addSSMUrlParametersToUrl, awsLogger } from './aws.utils'; import dotenv from 'dotenv'; import { Readable } from 'stream'; import NodeCache from 'node-cache'; import { randomInt } from 'crypto'; dotenv.config(); +import jwt from 'jsonwebtoken'; const version = process.env.npm_package_version; @@ -340,3 +346,35 @@ export function newState(state: RequestState): string { stateCache.set(key, state); return key; } + +export type JwtToken = { + company: string; + email: string; +}; + +export function authenticateToken(app: FastifyInstance): void { + const secret = process.env.JWT_SECRET; + if (secret != undefined) { + app.addHook( + 'onRequest', + async (request, reply): Promise => { + const token = request.query['token']; + if (token == undefined) + return reply.code(401).send({ error: 'No token provided' }); + try { + const censoredUrl = request.url.replace(token, 'TOKEN'); + const decoded = jwt.verify(token, secret) as JwtToken; + awsLogger.info( + { path: censoredUrl }, + request['awsLambda']?.['context'] as Context, + decoded + ); + } catch (err) { + return reply + .code(401) + .send({ error: 'Invalid authentication token' }); + } + } + ); + } +}