From 8cfa34638bdf4878509eb8597db4d2db6d58f3ed Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Mon, 15 Jun 2020 11:58:40 -0500 Subject: [PATCH 1/7] add JWT authorizer for HttpApi events --- src/ServerlessOffline.js | 2 +- src/config/commandOptions.js | 3 + src/events/http/HttpServer.js | 51 ++++- src/events/http/authJWTSettingsExtractor.js | 66 ++++++ src/events/http/createJWTAuthScheme.js | 98 +++++++++ .../LambdaProxyIntegrationEvent.js | 10 + tests/integration/jwt-authorizer/handler.js | 16 ++ .../jwt-authorizer/jwt-authorizer.test.js | 191 ++++++++++++++++++ .../integration/jwt-authorizer/serverless.yml | 37 ++++ 9 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 src/events/http/authJWTSettingsExtractor.js create mode 100644 src/events/http/createJWTAuthScheme.js create mode 100644 tests/integration/jwt-authorizer/handler.js create mode 100644 tests/integration/jwt-authorizer/jwt-authorizer.test.js create mode 100644 tests/integration/jwt-authorizer/serverless.yml diff --git a/src/ServerlessOffline.js b/src/ServerlessOffline.js index 9c9611414..21739718a 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -299,7 +299,7 @@ export default class ServerlessOffline { httpEvents.push({ functionKey, handler: functionDefinition.handler, - http: http || httpApi, + http: http || { ...httpApi, isHttpApi: true }, }) } diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index 9ee42e8ab..93f57c5f3 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -49,6 +49,9 @@ export default { noAuth: { usage: 'Turns off all authorizers', }, + allowExpiredJWT: { + usage: "Don't check the expiration of a JWT token", + }, noTimeout: { shortcut: 't', usage: 'Disables the timeout feature.', diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index 0eb7a698e..e0b20c063 100644 --- a/src/events/http/HttpServer.js +++ b/src/events/http/HttpServer.js @@ -4,7 +4,9 @@ import { join, resolve } from 'path' import h2o2 from '@hapi/h2o2' import { Server } from '@hapi/hapi' import authFunctionNameExtractor from './authFunctionNameExtractor.js' +import authJWTSettingsExtractor from './authJWTSettingsExtractor.js' import createAuthScheme from './createAuthScheme.js' +import createJWTAuthScheme from './createJWTAuthScheme.js' import Endpoint from './Endpoint.js' import { LambdaIntegrationEvent, @@ -186,6 +188,52 @@ export default class HttpServer { serverlessLog('https://github.com/dherault/serverless-offline/issues') } + _extractJWTAuthSettings(endpoint) { + const result = authJWTSettingsExtractor( + endpoint, + this.#serverless.service.provider, + this.#options.allowExpiredJWT, + ) + + return result.unsupportedAuth ? null : result + } + + _configureJWTAuthorization(endpoint, functionKey, method, path) { + if (!endpoint.authorizer) { + return null + } + + // right now _configureJWTAuthorization only handles AWS HttpAPI Gateway JWT + // authorizers that are defined in the serverless file + if ( + this.#serverless.service.provider.name !== 'aws' || + !endpoint.isHttpApi + ) { + return null + } + + serverlessLog(`Configuring JWT Authorization: ${method} ${path}`) + + const jwtSettings = this._extractJWTAuthSettings(endpoint) + + // Create a unique scheme per endpoint + // This allows the methodArn on the event property to be set appropriately + const authKey = `${functionKey}-${jwtSettings.authorizerName}-${method}-${path}` + const authSchemeName = `scheme-${authKey}` + const authStrategyName = `strategy-${authKey}` // set strategy name for the route config + + debugLog(`Creating Authorization scheme for ${authKey}`) + + // Create the Auth Scheme for the endpoint + const scheme = createJWTAuthScheme(jwtSettings) + + // Set the auth scheme and strategy on the server + this.#server.auth.scheme(authSchemeName, scheme) + this.#server.auth.strategy(authStrategyName, authSchemeName) + + return authStrategyName + } + _extractAuthFunctionName(endpoint) { const result = authFunctionNameExtractor(endpoint) @@ -278,7 +326,8 @@ export default class HttpServer { // If the endpoint has an authorization function, create an authStrategy for the route const authStrategyName = this.#options.noAuth ? null - : this._configureAuthorization(endpoint, functionKey, method, path) + : this._configureJWTAuthorization(endpoint, functionKey, method, path) || + this._configureAuthorization(endpoint, functionKey, method, path) let cors = null if (endpoint.cors) { diff --git a/src/events/http/authJWTSettingsExtractor.js b/src/events/http/authJWTSettingsExtractor.js new file mode 100644 index 000000000..67d375e9b --- /dev/null +++ b/src/events/http/authJWTSettingsExtractor.js @@ -0,0 +1,66 @@ +import serverlessLog from '../../serverlessLog.js' + +export default function authJWTSettingsExtractor( + endpoint, + provider, + allowExpiredJWT, +) { + const buildFailureResult = (warningMessage) => { + serverlessLog(warningMessage) + + return { unsupportedAuth: true } + } + + const buildSuccessResult = (authorizerName) => ({ authorizerName }) + + const { authorizer } = endpoint + + if (!authorizer) { + return buildSuccessResult(null) + } + + if (!provider.httpApi || !provider.httpApi.authorizers) { + return buildSuccessResult(null) + } + + if (!authorizer.name) { + return buildFailureResult( + 'WARNING: Serverless Offline supports only JWT authorizers referenced by name', + ) + } + + const httpApiAuthorizer = provider.httpApi.authorizers[authorizer.name] + + if (!httpApiAuthorizer) { + return buildFailureResult( + `WARNING: JWT authorizer ${authorizer.name} not found`, + ) + } + + if (!httpApiAuthorizer.identitySource) { + return buildFailureResult( + `WARNING: JWT authorizer ${authorizer.name} missing identity source`, + ) + } + + if (!httpApiAuthorizer.issuerUrl) { + return buildFailureResult( + `WARNING: JWT authorizer ${authorizer.name} missing issuer url`, + ) + } + + if (!httpApiAuthorizer.audience || httpApiAuthorizer.audience.length === 0) { + return buildFailureResult( + `WARNING: JWT authorizer ${authorizer.name} missing audience`, + ) + } + + const result = { + authorizerName: authorizer.name, + ...authorizer, + ...httpApiAuthorizer, + allowExpiredJWT, + } + + return result +} diff --git a/src/events/http/createJWTAuthScheme.js b/src/events/http/createJWTAuthScheme.js new file mode 100644 index 000000000..ee0d6ae65 --- /dev/null +++ b/src/events/http/createJWTAuthScheme.js @@ -0,0 +1,98 @@ +import Boom from '@hapi/boom' +import jwt from 'jsonwebtoken' +import serverlessLog from '../../serverlessLog.js' + +export default function createAuthScheme(jwtOptions) { + const authorizerName = jwtOptions.name + + const identitySourceMatch = /^\$request.header.((?:\w+-?)+\w+)$/.exec( + jwtOptions.identitySource, + ) + if (!identitySourceMatch || identitySourceMatch.length !== 2) { + throw new Error( + `Serverless Offline only supports retrieving JWT from the headers (${authorizerName})`, + ) + } + const identityHeader = identitySourceMatch[1].toLowerCase() + + // Create Auth Scheme + return () => ({ + async authenticate(request, h) { + console.log('') // Just to make things a little pretty + + // TODO: this only validates specific properties of the JWT + // it does not verify the JWT is correctly signed. That would + // be a great feature to add under an optional flag :) + + serverlessLog( + `Running JWT Authorization function for ${request.method} ${request.path} (${authorizerName})`, + ) + + // Get Authorization header + const { req } = request.raw + let jwtToken = req.headers[identityHeader] + if (jwtToken && jwtToken.split(' ')[0] === 'Bearer') { + ;[, jwtToken] = jwtToken.split(' ') + } + + try { + const decoded = jwt.decode(jwtToken, { complete: true }) + if (!jwtOptions.allowExpiredJWT) { + const expirationDate = new Date(decoded.payload.exp * 1000) + if (expirationDate.valueOf() < Date.now()) { + return Boom.forbidden('JWT Token expired') + } + } + + const { iss, aud, scope } = decoded.payload + const clientId = decoded.payload.client_id + if (iss !== jwtOptions.issuerUrl) { + serverlessLog(`JWT Token not from correct issuer url`) + return Boom.forbidden('JWT Token not from correct issuer url') + } + + if ( + !jwtOptions.audience.includes(aud) && + !jwtOptions.audience.includes(clientId) + ) { + serverlessLog(`JWT Token not from correct audience`) + return Boom.forbidden('JWT Token not from correct audience') + } + + let scopes = null + if (jwtOptions.scopes && jwtOptions.scopes.length) { + if (!scope) { + serverlessLog(`JWT Token missing valid scope`) + return Boom.forbidden('JWT Token missing valid scope') + } + + scopes = scope.split(' ') + if ( + scopes.every((s) => { + return !jwtOptions.scopes.includes(s) + }) + ) { + serverlessLog(`JWT Token missing valid scope`) + return Boom.forbidden('JWT Token missing valid scope') + } + } + + serverlessLog(`JWT Token validated`) + + // Set the credentials for the rest of the pipeline + // return resolve( + return h.authenticated({ + credentials: { + claims: decoded.payload, + scopes, + }, + }) + } catch (err) { + serverlessLog(`JWT could not be decoded`) + serverlessLog(err) + + return Boom.unauthorized('Unauthorized') + } + }, + }) +} diff --git a/src/events/http/lambda-events/LambdaProxyIntegrationEvent.js b/src/events/http/lambda-events/LambdaProxyIntegrationEvent.js index 94162143a..e0727b21b 100644 --- a/src/events/http/lambda-events/LambdaProxyIntegrationEvent.js +++ b/src/events/http/lambda-events/LambdaProxyIntegrationEvent.js @@ -100,10 +100,19 @@ export default class LambdaProxyIntegrationEvent { } let claims + let scopes if (token) { try { claims = decode(token) || undefined + if (claims && claims.scope) { + scopes = claims.scope.split(' ') + // In AWS HTTP Api the scope property is removed from the decoded JWT + // I'm leaving this property because I'm not sure how all of the authorizers + // for AWS REST Api handle JWT. + // claims = { ...claims } + // delete claims.scope + } } catch (err) { // Do nothing } @@ -142,6 +151,7 @@ export default class LambdaProxyIntegrationEvent { authAuthorizer || assign(authContext, { claims, + scopes, // 'principalId' should have higher priority principalId: authPrincipalId || diff --git a/tests/integration/jwt-authorizer/handler.js b/tests/integration/jwt-authorizer/handler.js new file mode 100644 index 000000000..7fbb4780c --- /dev/null +++ b/tests/integration/jwt-authorizer/handler.js @@ -0,0 +1,16 @@ +'use strict' + +const { stringify } = JSON + +exports.user = async function get(context) { + return { + body: stringify({ + status: 'authorized', + requestContext: { + claims: context.requestContext.authorizer.claims, + scopes: context.requestContext.authorizer.scopes, + }, + }), + statusCode: 200, + } +} diff --git a/tests/integration/jwt-authorizer/jwt-authorizer.test.js b/tests/integration/jwt-authorizer/jwt-authorizer.test.js new file mode 100644 index 000000000..9906dcb29 --- /dev/null +++ b/tests/integration/jwt-authorizer/jwt-authorizer.test.js @@ -0,0 +1,191 @@ +// tests based on: +// https://dev.to/piczmar_0/serverless-authorizers---custom-rest-authorizer-16 + +import crypto from 'crypto' +import { resolve } from 'path' +import fetch from 'node-fetch' +import jsonwebtoken from 'jsonwebtoken' +import { joinUrl, setup, teardown } from '../_testHelpers/index.js' + +jest.setTimeout(30000) + +const secret = crypto.randomBytes(256) + +const jwtSignOptions = { + algorithm: 'HS256', +} + +const baseJWT = { + sub: '584a5479-8943-45cd-8505-14cf3ccd92fa', + 'cognito:groups': ['testGroup1'], + iss: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_notreal', + version: 2, + client_id: 'ZjE4ZGVlYzUtMDU1Ni00ZWM4LThkMDAtYTlkMmIzNWE4NTNj', + event_id: '5d6f052a-0341-4da6-9c50-26426265c459', + token_use: 'access', + scope: 'profile email', + auth_time: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 5000, + iat: Math.floor(Date.now() / 1000), + jti: '9a2f8ae5-9a8d-4d88-be36-bc0a1e042718', + username: '805ac36b-cf7a-42e0-a9c3-029e12d724b2', +} + +const expiredJWT = { + ...baseJWT, + exp: Math.floor(Date.now() / 1000) - 2000, +} + +const wrongIssuerUrl = { + ...baseJWT, + iss: 'https://cognito-idp.us-east-1.amazonaws.com/us-east-2_reallynotreal', +} + +const wrongClientId = { + ...baseJWT, + client_id: 'wrong client', +} + +const wrongAudience = { + ...baseJWT, + aud: 'wrong aud', +} +delete wrongAudience.client_id + +const correctAudience = { + ...baseJWT, + aud: baseJWT.client_id, +} +delete correctAudience.client_id + +const noScopes = { + ...baseJWT, +} +delete noScopes.scope + +describe('jwt authorizer tests', () => { + // init + beforeAll(() => + setup({ + servicePath: resolve(__dirname), + }), + ) + + // cleanup + afterAll(() => teardown()) + + // + ;[ + { + description: 'Valid JWT', + expected: { + status: 'authorized', + requestContext: { + claims: baseJWT, + scopes: ['profile', 'email'], + }, + }, + jwt: baseJWT, + path: '/dev/user1', + status: 200, + }, + { + description: 'Valid JWT with audience', + expected: { + status: 'authorized', + requestContext: { + claims: correctAudience, + scopes: ['profile', 'email'], + }, + }, + jwt: correctAudience, + path: '/dev/user1', + status: 200, + }, + + { + description: 'Valid JWT with scopes', + expected: { + status: 'authorized', + requestContext: { + claims: baseJWT, + scopes: ['profile', 'email'], + }, + }, + jwt: baseJWT, + path: '/dev/user2', + status: 200, + }, + { + description: 'Expired JWT', + expected: { + statusCode: 403, + error: 'Forbidden', + message: 'JWT Token expired', + }, + jwt: expiredJWT, + path: '/dev/user1', + status: 403, + }, + { + description: 'Wrong Issuer Url', + expected: { + statusCode: 403, + error: 'Forbidden', + message: 'JWT Token not from correct issuer url', + }, + jwt: wrongIssuerUrl, + path: '/dev/user1', + status: 403, + }, + { + description: 'Wrong Client Id', + expected: { + statusCode: 403, + error: 'Forbidden', + message: 'JWT Token not from correct audience', + }, + jwt: wrongClientId, + path: '/dev/user1', + status: 403, + }, + { + description: 'Wrong Audience', + expected: { + statusCode: 403, + error: 'Forbidden', + message: 'JWT Token not from correct audience', + }, + jwt: wrongAudience, + path: '/dev/user1', + status: 403, + }, + { + description: 'Missing Scopes', + expected: { + statusCode: 403, + error: 'Forbidden', + message: 'JWT Token missing valid scope', + }, + jwt: noScopes, + path: '/dev/user2', + status: 403, + }, + ].forEach(({ description, expected, jwt, path, status }) => { + test(description, async () => { + const url = joinUrl(TEST_BASE_URL, path) + const signedJwt = jsonwebtoken.sign(jwt, secret, jwtSignOptions) + const options = { + headers: { + Authorization: `Bearer ${signedJwt}`, + }, + } + + const response = await fetch(url, options) + expect(response.status).toEqual(status) + + const json = await response.json() + expect(json).toEqual(expected) + }) + }) +}) diff --git a/tests/integration/jwt-authorizer/serverless.yml b/tests/integration/jwt-authorizer/serverless.yml new file mode 100644 index 000000000..9d3da72a4 --- /dev/null +++ b/tests/integration/jwt-authorizer/serverless.yml @@ -0,0 +1,37 @@ +service: jwt-authorizer + +plugins: + - ../../../src/main.js + +provider: + memorySize: 128 + name: aws + region: us-east-1 # default + runtime: nodejs12.x + stage: dev + versionFunctions: false + httpApi: + payload: '1.0' + authorizers: + jwtAuth: + identitySource: $request.header.Authorization + issuerUrl: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_notreal + audience: + - ZjE4ZGVlYzUtMDU1Ni00ZWM4LThkMDAtYTlkMmIzNWE4NTNj + +functions: + user: + events: + - httpApi: + authorizer: + name: jwtAuth + method: get + path: user1 + - httpApi: + authorizer: + name: jwtAuth + scopes: + - email + method: get + path: user2 + handler: handler.user From 880362d7d5ca46251839c6e4b4036e6ab5b290b4 Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Mon, 15 Jun 2020 14:21:10 -0500 Subject: [PATCH 2/7] change status codes to match api gateway --- src/events/http/createJWTAuthScheme.js | 10 +++++--- .../jwt-authorizer/jwt-authorizer.test.js | 24 +++++++++---------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/events/http/createJWTAuthScheme.js b/src/events/http/createJWTAuthScheme.js index ee0d6ae65..e2100a1fc 100644 --- a/src/events/http/createJWTAuthScheme.js +++ b/src/events/http/createJWTAuthScheme.js @@ -37,10 +37,14 @@ export default function createAuthScheme(jwtOptions) { try { const decoded = jwt.decode(jwtToken, { complete: true }) + if (!decoded) { + return Boom.unauthorized('JWT not decoded') + } + if (!jwtOptions.allowExpiredJWT) { const expirationDate = new Date(decoded.payload.exp * 1000) if (expirationDate.valueOf() < Date.now()) { - return Boom.forbidden('JWT Token expired') + return Boom.unauthorized('JWT Token expired') } } @@ -48,7 +52,7 @@ export default function createAuthScheme(jwtOptions) { const clientId = decoded.payload.client_id if (iss !== jwtOptions.issuerUrl) { serverlessLog(`JWT Token not from correct issuer url`) - return Boom.forbidden('JWT Token not from correct issuer url') + return Boom.unauthorized('JWT Token not from correct issuer url') } if ( @@ -56,7 +60,7 @@ export default function createAuthScheme(jwtOptions) { !jwtOptions.audience.includes(clientId) ) { serverlessLog(`JWT Token not from correct audience`) - return Boom.forbidden('JWT Token not from correct audience') + return Boom.unauthorized('JWT Token not from correct audience') } let scopes = null diff --git a/tests/integration/jwt-authorizer/jwt-authorizer.test.js b/tests/integration/jwt-authorizer/jwt-authorizer.test.js index 9906dcb29..64105329e 100644 --- a/tests/integration/jwt-authorizer/jwt-authorizer.test.js +++ b/tests/integration/jwt-authorizer/jwt-authorizer.test.js @@ -119,46 +119,46 @@ describe('jwt authorizer tests', () => { { description: 'Expired JWT', expected: { - statusCode: 403, - error: 'Forbidden', + statusCode: 401, + error: 'Unauthorized', message: 'JWT Token expired', }, jwt: expiredJWT, path: '/dev/user1', - status: 403, + status: 401, }, { description: 'Wrong Issuer Url', expected: { - statusCode: 403, - error: 'Forbidden', + statusCode: 401, + error: 'Unauthorized', message: 'JWT Token not from correct issuer url', }, jwt: wrongIssuerUrl, path: '/dev/user1', - status: 403, + status: 401, }, { description: 'Wrong Client Id', expected: { - statusCode: 403, - error: 'Forbidden', + statusCode: 401, + error: 'Unauthorized', message: 'JWT Token not from correct audience', }, jwt: wrongClientId, path: '/dev/user1', - status: 403, + status: 401, }, { description: 'Wrong Audience', expected: { - statusCode: 403, - error: 'Forbidden', + statusCode: 401, + error: 'Unauthorized', message: 'JWT Token not from correct audience', }, jwt: wrongAudience, path: '/dev/user1', - status: 403, + status: 401, }, { description: 'Missing Scopes', From 4400b23050c031d562c28c24d3c4710092c43642 Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Mon, 15 Jun 2020 18:22:44 -0500 Subject: [PATCH 3/7] require feature flag for this functionality --- src/config/commandOptions.js | 5 +++-- src/events/http/HttpServer.js | 9 ++++++--- src/events/http/authJWTSettingsExtractor.js | 8 ++++++-- src/events/http/createJWTAuthScheme.js | 8 +++----- tests/integration/jwt-authorizer/jwt-authorizer.test.js | 1 + tests/integration/jwt-authorizer/serverless.yml | 2 +- 6 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index 93f57c5f3..3daa95831 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -49,8 +49,9 @@ export default { noAuth: { usage: 'Turns off all authorizers', }, - allowExpiredJWT: { - usage: "Don't check the expiration of a JWT token", + ignoreJWTSignature: { + usage: + "Don't check the signature of a JWT token. This should only be used for local development.", }, noTimeout: { shortcut: 't', diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index e0b20c063..98080ef92 100644 --- a/src/events/http/HttpServer.js +++ b/src/events/http/HttpServer.js @@ -192,7 +192,7 @@ export default class HttpServer { const result = authJWTSettingsExtractor( endpoint, this.#serverless.service.provider, - this.#options.allowExpiredJWT, + this.#options.ignoreJWTSignature, ) return result.unsupportedAuth ? null : result @@ -212,9 +212,12 @@ export default class HttpServer { return null } - serverlessLog(`Configuring JWT Authorization: ${method} ${path}`) - const jwtSettings = this._extractJWTAuthSettings(endpoint) + if (!jwtSettings) { + return null + } + + serverlessLog(`Configuring JWT Authorization: ${method} ${path}`) // Create a unique scheme per endpoint // This allows the methodArn on the event property to be set appropriately diff --git a/src/events/http/authJWTSettingsExtractor.js b/src/events/http/authJWTSettingsExtractor.js index 67d375e9b..d30f01886 100644 --- a/src/events/http/authJWTSettingsExtractor.js +++ b/src/events/http/authJWTSettingsExtractor.js @@ -3,7 +3,7 @@ import serverlessLog from '../../serverlessLog.js' export default function authJWTSettingsExtractor( endpoint, provider, - allowExpiredJWT, + ignoreJWTSignature, ) { const buildFailureResult = (warningMessage) => { serverlessLog(warningMessage) @@ -23,6 +23,11 @@ export default function authJWTSettingsExtractor( return buildSuccessResult(null) } + // TODO: add code that will actually validate a JWT. + if (!ignoreJWTSignature) { + return buildSuccessResult(null) + } + if (!authorizer.name) { return buildFailureResult( 'WARNING: Serverless Offline supports only JWT authorizers referenced by name', @@ -59,7 +64,6 @@ export default function authJWTSettingsExtractor( authorizerName: authorizer.name, ...authorizer, ...httpApiAuthorizer, - allowExpiredJWT, } return result diff --git a/src/events/http/createJWTAuthScheme.js b/src/events/http/createJWTAuthScheme.js index e2100a1fc..3fc7afe42 100644 --- a/src/events/http/createJWTAuthScheme.js +++ b/src/events/http/createJWTAuthScheme.js @@ -41,11 +41,9 @@ export default function createAuthScheme(jwtOptions) { return Boom.unauthorized('JWT not decoded') } - if (!jwtOptions.allowExpiredJWT) { - const expirationDate = new Date(decoded.payload.exp * 1000) - if (expirationDate.valueOf() < Date.now()) { - return Boom.unauthorized('JWT Token expired') - } + const expirationDate = new Date(decoded.payload.exp * 1000) + if (expirationDate.valueOf() < Date.now()) { + return Boom.unauthorized('JWT Token expired') } const { iss, aud, scope } = decoded.payload diff --git a/tests/integration/jwt-authorizer/jwt-authorizer.test.js b/tests/integration/jwt-authorizer/jwt-authorizer.test.js index 64105329e..0e9fbb30f 100644 --- a/tests/integration/jwt-authorizer/jwt-authorizer.test.js +++ b/tests/integration/jwt-authorizer/jwt-authorizer.test.js @@ -68,6 +68,7 @@ describe('jwt authorizer tests', () => { beforeAll(() => setup({ servicePath: resolve(__dirname), + args: ['--ignoreJWTSignature'], }), ) diff --git a/tests/integration/jwt-authorizer/serverless.yml b/tests/integration/jwt-authorizer/serverless.yml index 9d3da72a4..425061208 100644 --- a/tests/integration/jwt-authorizer/serverless.yml +++ b/tests/integration/jwt-authorizer/serverless.yml @@ -1,7 +1,7 @@ service: jwt-authorizer plugins: - - ../../../src/main.js + - ../../../ provider: memorySize: 128 From 6df1cefcb53fdaa26479a99b0d8a62b020363317 Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Fri, 19 Jun 2020 09:13:29 -0500 Subject: [PATCH 4/7] add support for events defined as a string --- src/ServerlessOffline.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ServerlessOffline.js b/src/ServerlessOffline.js index 21739718a..d46f63520 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -296,11 +296,20 @@ export default class ServerlessOffline { const { http, httpApi, schedule, websocket } = event if ((http || httpApi) && functionDefinition.handler) { - httpEvents.push({ + const httpEvent = { functionKey, handler: functionDefinition.handler, - http: http || { ...httpApi, isHttpApi: true }, - }) + http: http || httpApi, + } + // this is here to allow rawHttpEventDefinition to be a string + // the problem is that events defined as + // httpApi: '*' + // will not have the isHttpApi flag set. This will need to be addressed + // when adding support for HttpApi 2.0 payload types. + if (httpApi && typeof httpApi === 'object') { + httpEvent.http = { ...httpApi, isHttpApi: true } + } + httpEvents.push(httpEvent) } if (schedule) { From 4366059e0432de21178e901e3cfbd3228338eeaa Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Fri, 19 Jun 2020 09:41:56 -0500 Subject: [PATCH 5/7] code review fix --- tests/integration/jwt-authorizer/handler.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/integration/jwt-authorizer/handler.js b/tests/integration/jwt-authorizer/handler.js index 7fbb4780c..ec71f74ef 100644 --- a/tests/integration/jwt-authorizer/handler.js +++ b/tests/integration/jwt-authorizer/handler.js @@ -1,10 +1,8 @@ 'use strict' -const { stringify } = JSON - exports.user = async function get(context) { return { - body: stringify({ + body: JSON.stringify({ status: 'authorized', requestContext: { claims: context.requestContext.authorizer.claims, From bc907b1b03c0d7b4ff80ff077dd6adbcf36ea5a5 Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Sat, 20 Jun 2020 08:23:13 -0500 Subject: [PATCH 6/7] trigger build --- src/ServerlessOffline.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ServerlessOffline.js b/src/ServerlessOffline.js index d46f63520..0160cc2d9 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -309,6 +309,7 @@ export default class ServerlessOffline { if (httpApi && typeof httpApi === 'object') { httpEvent.http = { ...httpApi, isHttpApi: true } } + httpEvents.push(httpEvent) } From f8efd51c2a08500175b1dbb7b1711fe30ffd318e Mon Sep 17 00:00:00 2001 From: Quenby Mitchell Date: Mon, 22 Jun 2020 18:25:28 -0500 Subject: [PATCH 7/7] add documentation --- README.md | 24 ++++++++++++++++++++++-- package.json | 1 + src/config/commandOptions.js | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9a1c11acc..9c75bb72a 100644 --- a/README.md +++ b/README.md @@ -42,22 +42,34 @@ This plugin is updated by its users, I just do maintenance and ensure that PRs a - [Installation](#installation) - [Usage and command line options](#usage-and-command-line-options) -- [Usage with invoke](#usage-with-invoke) +- [Usage with `invoke`](#usage-with-invoke) +- [The `process.env.IS_OFFLINE` variable](#the-processenvis_offline-variable) - [Token authorizers](#token-authorizers) - [Custom authorizers](#custom-authorizers) - [Remote authorizers](#remote-authorizers) +- [JWT authorizers](#jwt-authorizers) - [Custom headers](#custom-headers) - [Environment variables](#environment-variables) -- [AWS API Gateway features](#aws-api-gateway-features) +- [AWS API Gateway Features](#aws-api-gateway-features) + - [Velocity Templates](#velocity-templates) + - [CORS](#cors) + - [Catch-all Path Variables](#catch-all-path-variables) + - [ANY method](#any-method) + - [Lambda and Lambda Proxy Integrations](#lambda-and-lambda-proxy-integrations) + - [HTTP Proxy](#http-proxy) + - [Response parameters](#response-parameters) - [WebSocket](#websocket) - [Usage with Webpack](#usage-with-webpack) - [Velocity nuances](#velocity-nuances) - [Debug process](#debug-process) +- [Resource permissions and AWS profile](#resource-permissions-and-aws-profile) - [Scoped execution](#scoped-execution) - [Simulation quality](#simulation-quality) +- [Usage with serverless-dynamodb-local and serverless-webpack plugin](#usage-with-serverless-dynamodb-local-and-serverless-webpack-plugin) - [Credits and inspiration](#credits-and-inspiration) - [License](#license) - [Contributing](#contributing) +- [Contributors](#contributors) ## Installation @@ -104,6 +116,7 @@ All CLI options are optional: --host -o Host name to listen on. Default: localhost --httpPort Http port to listen on. Default: 3000 --httpsProtocol -H To enable HTTPS, specify directory (relative to your cwd, typically your project dir) for both cert.pem and key.pem files +--ignoreJWTSignature When using HttpApi with a JWT authorizer, don't check the signature of the JWT token. This should only be used for local development. --lambdaPort Lambda http port to listen on. Default: 3002 --noPrependStageInUrl Don't prepend http routes with the stage. --noAuth Turns off all authorizers @@ -223,6 +236,13 @@ Example: > Windows: `SET AUTHORIZER='{"principalId": "123"}'` +## JWT authorizers + +For HTTP APIs, [JWT authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html) +defined in the `serverless.yml` can be used to validate the token and scopes in the token. However at this time, +the signature of the JWT is not validated with the defined issuer. Since this is a security risk, this feature is +only enabled with the `--ignoreJWTSignature` flag. Make sure to only set this flag for local development work. + ## Custom headers You are able to use some custom headers in your request to gain more control over the requestContext object. diff --git a/package.json b/package.json index 401e9a1d5..38bf7d5b3 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "Paul Pasmanik (https://github.com/ppasmanik)", "Piotr Gasiorowski (https://github.com/WooDzu)", "polaris340 (https://github.com/polaris340)", + "Quenby Mitchell (https://github.com/qswinson)", "Ram Hardy (https://github.com/computerpunc)", "Ramon Emilio Savinon (https://github.com/vaberay)", "Rob Brazier (https://github.com/robbrazier)", diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index 3daa95831..baacd44a2 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -51,7 +51,7 @@ export default { }, ignoreJWTSignature: { usage: - "Don't check the signature of a JWT token. This should only be used for local development.", + "When using HttpApi with a JWT authorizer, don't check the signature of the JWT token. This should only be used for local development.", }, noTimeout: { shortcut: 't',