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 f41e25de0..7731bd105 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,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/ServerlessOffline.js b/src/ServerlessOffline.js index 9c9611414..0160cc2d9 100644 --- a/src/ServerlessOffline.js +++ b/src/ServerlessOffline.js @@ -296,11 +296,21 @@ 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, - }) + } + // 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) { diff --git a/src/config/commandOptions.js b/src/config/commandOptions.js index 9ee42e8ab..baacd44a2 100644 --- a/src/config/commandOptions.js +++ b/src/config/commandOptions.js @@ -49,6 +49,10 @@ export default { noAuth: { usage: 'Turns off all authorizers', }, + ignoreJWTSignature: { + usage: + "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', usage: 'Disables the timeout feature.', diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index 0eb7a698e..98080ef92 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,55 @@ export default class HttpServer { serverlessLog('https://github.com/dherault/serverless-offline/issues') } + _extractJWTAuthSettings(endpoint) { + const result = authJWTSettingsExtractor( + endpoint, + this.#serverless.service.provider, + this.#options.ignoreJWTSignature, + ) + + 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 + } + + 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 + 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 +329,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..d30f01886 --- /dev/null +++ b/src/events/http/authJWTSettingsExtractor.js @@ -0,0 +1,70 @@ +import serverlessLog from '../../serverlessLog.js' + +export default function authJWTSettingsExtractor( + endpoint, + provider, + ignoreJWTSignature, +) { + 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) + } + + // 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', + ) + } + + 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, + } + + return result +} diff --git a/src/events/http/createJWTAuthScheme.js b/src/events/http/createJWTAuthScheme.js new file mode 100644 index 000000000..3fc7afe42 --- /dev/null +++ b/src/events/http/createJWTAuthScheme.js @@ -0,0 +1,100 @@ +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 (!decoded) { + return Boom.unauthorized('JWT not decoded') + } + + 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 + const clientId = decoded.payload.client_id + if (iss !== jwtOptions.issuerUrl) { + serverlessLog(`JWT Token not from correct issuer url`) + return Boom.unauthorized('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.unauthorized('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..ec71f74ef --- /dev/null +++ b/tests/integration/jwt-authorizer/handler.js @@ -0,0 +1,14 @@ +'use strict' + +exports.user = async function get(context) { + return { + body: JSON.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..0e9fbb30f --- /dev/null +++ b/tests/integration/jwt-authorizer/jwt-authorizer.test.js @@ -0,0 +1,192 @@ +// 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), + args: ['--ignoreJWTSignature'], + }), + ) + + // 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: 401, + error: 'Unauthorized', + message: 'JWT Token expired', + }, + jwt: expiredJWT, + path: '/dev/user1', + status: 401, + }, + { + description: 'Wrong Issuer Url', + expected: { + statusCode: 401, + error: 'Unauthorized', + message: 'JWT Token not from correct issuer url', + }, + jwt: wrongIssuerUrl, + path: '/dev/user1', + status: 401, + }, + { + description: 'Wrong Client Id', + expected: { + statusCode: 401, + error: 'Unauthorized', + message: 'JWT Token not from correct audience', + }, + jwt: wrongClientId, + path: '/dev/user1', + status: 401, + }, + { + description: 'Wrong Audience', + expected: { + statusCode: 401, + error: 'Unauthorized', + message: 'JWT Token not from correct audience', + }, + jwt: wrongAudience, + path: '/dev/user1', + status: 401, + }, + { + 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..425061208 --- /dev/null +++ b/tests/integration/jwt-authorizer/serverless.yml @@ -0,0 +1,37 @@ +service: jwt-authorizer + +plugins: + - ../../../ + +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