serverless-offline 11.2.3 → 11.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ This plugin is updated by its users, I just do maintenance and ensure that PRs a
43
43
  - [Installation](#installation)
44
44
  - [Usage and command line options](#usage-and-command-line-options)
45
45
  - [Run modes](#run-modes)
46
- - [Usage with `invoke`](#usage-with-invoke)
46
+ - [Invoke Lambda](#invoke-lambda)
47
47
  - [The `process.env.IS_OFFLINE` variable](#the-processenvis_offline-variable)
48
48
  - [Docker and Layers](#docker-and-layers)
49
49
  - [Authorizers](#authorizers)
@@ -295,7 +295,7 @@ Lambda handlers for the `node.js` runtime can run in different execution modes w
295
295
 
296
296
  the Lambda handler process is running in a child process.
297
297
 
298
- ## Usage with `invoke`
298
+ ## Invoke Lambda
299
299
 
300
300
  To use `Lambda.invoke` you need to set the lambda endpoint to the `serverless-offline` endpoint:
301
301
 
@@ -326,14 +326,20 @@ const lambda = new aws.Lambda({
326
326
  })
327
327
 
328
328
  export async function handler() {
329
- const clientContextData = stringify({ foo: 'foo' })
329
+ const clientContextData = stringify({
330
+ foo: 'foo',
331
+ })
332
+
333
+ const payload = stringify({
334
+ data: 'foo',
335
+ })
330
336
 
331
337
  const params = {
332
338
  ClientContext: Buffer.from(clientContextData).toString('base64'),
333
339
  // FunctionName is composed of: service name - stage - function name, e.g.
334
340
  FunctionName: 'myServiceName-dev-invokedHandler',
335
341
  InvocationType: 'RequestResponse',
336
- Payload: stringify({ data: 'foo' }),
342
+ Payload: payload,
337
343
  }
338
344
 
339
345
  const response = await lambda.invoke(params).promise()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "dedicatedTo": "Blue, a great migrating bird.",
3
3
  "name": "serverless-offline",
4
- "version": "11.2.3",
4
+ "version": "11.4.0",
5
5
  "description": "Emulate AWS λ and API Gateway locally when developing your Serverless project",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -82,46 +82,46 @@
82
82
  ]
83
83
  },
84
84
  "dependencies": {
85
- "@aws-sdk/client-lambda": "^3.200.0",
85
+ "@aws-sdk/client-lambda": "^3.210.0",
86
86
  "@hapi/boom": "^10.0.0",
87
87
  "@hapi/h2o2": "^10.0.0",
88
- "@hapi/hapi": "^20.2.2",
89
- "@serverless/utils": "^6.8.1",
88
+ "@hapi/hapi": "^21.0.0",
89
+ "@serverless/utils": "^6.8.2",
90
90
  "boxen": "^7.0.0",
91
91
  "chalk": "^5.1.2",
92
92
  "execa": "^6.1.0",
93
93
  "fs-extra": "^10.1.0",
94
94
  "is-wsl": "^2.2.0",
95
95
  "java-invoke-local": "0.0.6",
96
- "jose": "^4.10.4",
96
+ "jose": "^4.11.0",
97
97
  "js-string-escape": "^1.0.1",
98
98
  "jsonpath-plus": "^7.2.0",
99
99
  "jsonschema": "^1.4.1",
100
100
  "jszip": "^3.10.1",
101
101
  "luxon": "^3.1.0",
102
- "node-fetch": "^3.2.10",
102
+ "node-fetch": "^3.3.0",
103
103
  "node-schedule": "^2.1.0",
104
- "object.hasown": "^1.1.1",
104
+ "object.hasown": "^1.1.2",
105
105
  "p-memoize": "^7.1.1",
106
106
  "p-retry": "^5.1.1",
107
107
  "velocityjs": "^2.0.6",
108
- "ws": "^8.10.0"
108
+ "ws": "^8.11.0"
109
109
  },
110
110
  "devDependencies": {
111
111
  "@istanbuljs/esm-loader-hook": "^0.2.0",
112
112
  "archiver": "^5.3.1",
113
- "eslint": "^8.26.0",
113
+ "eslint": "^8.27.0",
114
114
  "eslint-config-airbnb-base": "^15.0.0",
115
115
  "eslint-config-prettier": "^8.5.0",
116
116
  "eslint-plugin-import": "^2.25.4",
117
117
  "eslint-plugin-prettier": "^4.2.1",
118
118
  "git-list-updated": "^1.2.1",
119
- "husky": "^8.0.1",
119
+ "husky": "^8.0.2",
120
120
  "lint-staged": "^13.0.3",
121
121
  "mocha": "^10.1.0",
122
122
  "nyc": "^15.1.0",
123
123
  "prettier": "^2.7.1",
124
- "serverless": "^3.23.0",
124
+ "serverless": "^3.24.1",
125
125
  "standard-version": "^9.5.0"
126
126
  },
127
127
  "peerDependencies": {
@@ -253,6 +253,16 @@ export default class HttpServer {
253
253
  return null
254
254
  }
255
255
 
256
+ if (
257
+ (endpoint.authorizer.name &&
258
+ this.#serverless.service.provider?.httpApi?.authorizers?.[
259
+ endpoint.authorizer.name
260
+ ]?.type === 'request') ||
261
+ endpoint.authorizer.type === 'request'
262
+ ) {
263
+ return null
264
+ }
265
+
256
266
  const jwtSettings = this.#extractJWTAuthSettings(endpoint)
257
267
  if (!jwtSettings) {
258
268
  return null
@@ -303,11 +313,36 @@ export default class HttpServer {
303
313
  log.error(`Authorization function ${authFunctionName} does not exist`)
304
314
  return null
305
315
  }
316
+ const serverlessAuthorizerOptions =
317
+ this.#serverless.service.provider.httpApi &&
318
+ this.#serverless.service.provider.httpApi.authorizers &&
319
+ this.#serverless.service.provider.httpApi.authorizers[authFunctionName]
306
320
 
307
321
  const authorizerOptions = {
308
- identitySource: 'method.request.header.Authorization',
309
- identityValidationExpression: '(.*)',
310
- resultTtlInSeconds: '300',
322
+ enableSimpleResponses:
323
+ (endpoint.isHttpApi &&
324
+ serverlessAuthorizerOptions?.enableSimpleResponses) ||
325
+ false,
326
+ identitySource:
327
+ serverlessAuthorizerOptions?.identitySource ||
328
+ 'method.request.header.Authorization',
329
+ identityValidationExpression:
330
+ serverlessAuthorizerOptions?.identityValidationExpression || '(.*)',
331
+ payloadVersion: !endpoint.isHttpApi
332
+ ? '1.0'
333
+ : serverlessAuthorizerOptions?.payloadVersion || '2.0',
334
+ resultTtlInSeconds:
335
+ serverlessAuthorizerOptions?.resultTtlInSeconds || '300',
336
+ }
337
+
338
+ if (
339
+ authorizerOptions.enableSimpleResponses &&
340
+ authorizerOptions.payloadVersion === '1.0'
341
+ ) {
342
+ log.error(
343
+ `Cannot create Authorization function '${authFunctionName}' if payloadVersion is '1.0' and enableSimpleResponses is true`,
344
+ )
345
+ return null
311
346
  }
312
347
 
313
348
  if (typeof endpoint.authorizer === 'string') {
@@ -3,6 +3,7 @@ import { log } from '@serverless/utils/log.js'
3
3
  import authCanExecuteResource from '../authCanExecuteResource.js'
4
4
  import authValidateContext from '../authValidateContext.js'
5
5
  import {
6
+ getRawQueryParams,
6
7
  nullIfEmpty,
7
8
  parseHeaders,
8
9
  parseMultiValueHeaders,
@@ -10,83 +11,77 @@ import {
10
11
  parseQueryStringParameters,
11
12
  } from '../../utils/index.js'
12
13
 
14
+ const IDENTITY_SOURCE_TYPE_HEADER = 'header'
15
+ const IDENTITY_SOURCE_TYPE_QUERYSTRING = 'querystring'
16
+
13
17
  export default function createAuthScheme(authorizerOptions, provider, lambda) {
14
18
  const authFunName = authorizerOptions.name
15
- let identityHeader = 'authorization'
19
+ let identitySourceField = 'authorization'
20
+ let identitySourceType = IDENTITY_SOURCE_TYPE_HEADER
16
21
 
17
- if (authorizerOptions.type !== 'request') {
18
- const identitySourceMatch = /^method.request.header.((?:\w+-?)+\w+)$/.exec(
19
- authorizerOptions.identitySource,
20
- )
22
+ const finalizeAuthScheme = () => {
23
+ return () => ({
24
+ async authenticate(request, h) {
25
+ log.notice()
26
+ log.notice(
27
+ `Running Authorization function for ${request.method} ${request.path} (λ: ${authFunName})`,
28
+ )
21
29
 
22
- if (!identitySourceMatch || identitySourceMatch.length !== 2) {
23
- throw new Error(
24
- `Serverless Offline only supports retrieving tokens from the headers (λ: ${authFunName})`,
25
- )
26
- }
30
+ const { rawHeaders, url } = request.raw.req
27
31
 
28
- identityHeader = identitySourceMatch[1].toLowerCase()
29
- }
32
+ // Get path params
33
+ // aws doesn't auto decode path params - hapi does
34
+ const pathParams = { ...request.params }
35
+
36
+ const accountId = 'random-account-id'
37
+ const apiId = 'random-api-id'
38
+ const requestId = 'random-request-id'
39
+
40
+ const httpMethod = request.method.toUpperCase()
41
+ const resourcePath = request.route.path.replace(
42
+ new RegExp(`^/${provider.stage}`),
43
+ '',
44
+ )
30
45
 
31
- // Create Auth Scheme
32
- return () => ({
33
- async authenticate(request, h) {
34
- log.notice()
35
- log.notice(
36
- `Running Authorization function for ${request.method} ${request.path} (λ: ${authFunName})`,
37
- )
38
-
39
- // Get Authorization header
40
- const { req } = request.raw
41
-
42
- // Get path params
43
- // aws doesn't auto decode path params - hapi does
44
- const pathParams = { ...request.params }
45
-
46
- const accountId = 'random-account-id'
47
- const apiId = 'random-api-id'
48
- const httpMethod = request.method.toUpperCase()
49
- const resourcePath = request.route.path.replace(
50
- new RegExp(`^/${provider.stage}`),
51
- '',
52
- )
53
-
54
- let event = {
55
- enhancedAuthContext: {},
56
- methodArn: `arn:aws:execute-api:${provider.region}:${accountId}:${apiId}/${provider.stage}/${httpMethod}${resourcePath}`,
57
- requestContext: {
58
- accountId,
59
- apiId,
60
- httpMethod,
61
- path: request.path,
62
- requestId: 'random-request-id',
63
- resourceId: 'random-resource-id',
64
- resourcePath,
65
- stage: provider.stage,
66
- },
67
- resource: resourcePath,
68
- }
69
-
70
- // Create event Object for authFunction
71
- // methodArn is the ARN of the function we are running we are authorizing access to (or not)
72
- // Account ID and API ID are not simulated
73
- if (authorizerOptions.type === 'request') {
74
- const { rawHeaders, url } = req
75
-
76
- event = {
77
- ...event,
46
+ let event = {
47
+ enhancedAuthContext: {},
78
48
  headers: parseHeaders(rawHeaders),
79
- httpMethod: request.method.toUpperCase(),
80
- multiValueHeaders: parseMultiValueHeaders(rawHeaders),
81
- multiValueQueryStringParameters:
82
- parseMultiValueQueryStringParameters(url),
83
- path: request.path,
84
- pathParameters: nullIfEmpty(pathParams),
85
- queryStringParameters: parseQueryStringParameters(url),
86
- type: 'REQUEST',
49
+ requestContext: {
50
+ accountId,
51
+ apiId,
52
+ domainName: `${apiId}.execute-api.us-east-1.amazonaws.com`,
53
+ domainPrefix: apiId,
54
+ requestId,
55
+ stage: provider.stage,
56
+ },
57
+ version: authorizerOptions.payloadVersion,
58
+ }
59
+
60
+ const protocol = `${request.server.info.protocol.toUpperCase()}/${
61
+ request.raw.req.httpVersion
62
+ }`
63
+ const currentDate = new Date()
64
+ const resourceId = `${httpMethod} ${resourcePath}`
65
+ const methodArn = `arn:aws:execute-api:${provider.region}:${accountId}:${apiId}/${provider.stage}/${httpMethod}${resourcePath}`
66
+
67
+ let authorization
68
+ if (identitySourceType === IDENTITY_SOURCE_TYPE_HEADER) {
69
+ const headers = request.raw.req.headers ?? {}
70
+ authorization = headers[identitySourceField]
71
+ } else if (identitySourceType === IDENTITY_SOURCE_TYPE_QUERYSTRING) {
72
+ const queryStringParameters = parseQueryStringParameters(url) ?? {}
73
+ authorization = queryStringParameters[identitySourceField]
74
+ } else {
75
+ throw new Error(
76
+ `No Authorization source has been specified. This should never happen. (λ: ${authFunName})`,
77
+ )
78
+ }
79
+
80
+ if (authorization === undefined) {
81
+ throw new Error(
82
+ `Identity Source is null for ${identitySourceType} ${identitySourceField} (λ: ${authFunName})`,
83
+ )
87
84
  }
88
- } else {
89
- const authorization = req.headers[identityHeader]
90
85
 
91
86
  const identityValidationExpression = new RegExp(
92
87
  authorizerOptions.identityValidationExpression,
@@ -95,82 +90,208 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) {
95
90
  identityValidationExpression.test(authorization)
96
91
  const finalAuthorization = matchedAuthorization ? authorization : ''
97
92
 
98
- log.debug(`Retrieved ${identityHeader} header "${finalAuthorization}"`)
93
+ log.debug(
94
+ `Retrieved ${identitySourceField} ${identitySourceType} "${finalAuthorization}"`,
95
+ )
99
96
 
100
- event = {
101
- ...event,
102
- authorizationToken: finalAuthorization,
103
- type: 'TOKEN',
97
+ if (authorizerOptions.payloadVersion === '1.0') {
98
+ event = {
99
+ ...event,
100
+ authorizationToken: finalAuthorization,
101
+ httpMethod: request.method.toUpperCase(),
102
+ identitySource: finalAuthorization,
103
+ methodArn,
104
+ multiValueHeaders: parseMultiValueHeaders(rawHeaders),
105
+ multiValueQueryStringParameters:
106
+ parseMultiValueQueryStringParameters(url),
107
+ path: request.path,
108
+ pathParameters: nullIfEmpty(pathParams),
109
+ queryStringParameters: parseQueryStringParameters(url),
110
+ requestContext: {
111
+ extendedRequestId: requestId,
112
+ httpMethod,
113
+ path: request.path,
114
+ protocol,
115
+ requestTime: currentDate.toString(),
116
+ requestTimeEpoch: currentDate.getTime(),
117
+ resourceId,
118
+ resourcePath,
119
+ stage: provider.stage,
120
+ },
121
+ resource: resourcePath,
122
+ }
104
123
  }
105
- }
106
124
 
107
- const lambdaFunction = lambda.get(authFunName)
108
- lambdaFunction.setEvent(event)
125
+ if (authorizerOptions.payloadVersion === '2.0') {
126
+ event = {
127
+ ...event,
128
+ identitySource: [finalAuthorization],
129
+ rawPath: request.path,
130
+ rawQueryString: getRawQueryParams(url),
131
+ requestContext: {
132
+ http: {
133
+ method: httpMethod,
134
+ path: resourcePath,
135
+ protocol,
136
+ },
137
+ routeKey: resourceId,
138
+ time: currentDate.toString(),
139
+ timeEpoch: currentDate.getTime(),
140
+ },
141
+ routeArn: methodArn,
142
+ routeKey: resourceId,
143
+ }
144
+ }
109
145
 
110
- try {
111
- const result = await lambdaFunction.runHandler()
112
- if (result === 'Unauthorized') return Boom.unauthorized('Unauthorized')
146
+ // methodArn is the ARN of the function we are running we are authorizing access to (or not)
147
+ // Account ID and API ID are not simulated
148
+ if (authorizerOptions.type === 'request') {
149
+ event = {
150
+ ...event,
151
+ type: 'REQUEST',
152
+ }
153
+ } else {
154
+ // This is safe since type: 'TOKEN' cannot have payload format 2.0
155
+ event = {
156
+ ...event,
157
+ type: 'TOKEN',
158
+ }
159
+ }
160
+
161
+ const lambdaFunction = lambda.get(authFunName)
162
+ lambdaFunction.setEvent(event)
163
+
164
+ try {
165
+ const result = await lambdaFunction.runHandler()
166
+
167
+ if (authorizerOptions.enableSimpleResponses) {
168
+ if (result.isAuthorized) {
169
+ const authorizer = {
170
+ integrationLatency: '42',
171
+ ...result.context,
172
+ }
173
+ return h.authenticated({
174
+ credentials: {
175
+ authorizer,
176
+ context: result.context || {},
177
+ },
178
+ })
179
+ }
180
+ return Boom.forbidden(
181
+ 'User is not authorized to access this resource',
182
+ )
183
+ }
184
+
185
+ if (result === 'Unauthorized')
186
+ return Boom.unauthorized('Unauthorized')
187
+
188
+ // Validate that the policy document has the principalId set
189
+ if (!result.principalId) {
190
+ log.notice(
191
+ `Authorization response did not include a principalId: (λ: ${authFunName})`,
192
+ )
193
+
194
+ return Boom.forbidden('No principalId set on the Response')
195
+ }
196
+
197
+ if (
198
+ !authCanExecuteResource(
199
+ result.policyDocument,
200
+ event.methodArn || event.routeArn,
201
+ )
202
+ ) {
203
+ log.notice(
204
+ `Authorization response didn't authorize user to access resource: (λ: ${authFunName})`,
205
+ )
206
+
207
+ return Boom.forbidden(
208
+ 'User is not authorized to access this resource',
209
+ )
210
+ }
211
+
212
+ // validate the resulting context, ensuring that all
213
+ // values are either string, number, or boolean types
214
+ if (result.context) {
215
+ const validationResult = authValidateContext(
216
+ result.context,
217
+ authFunName,
218
+ )
219
+
220
+ if (validationResult instanceof Error) {
221
+ return validationResult
222
+ }
223
+
224
+ result.context = validationResult
225
+ }
113
226
 
114
- // Validate that the policy document has the principalId set
115
- if (!result.principalId) {
116
227
  log.notice(
117
- `Authorization response did not include a principalId: (λ: ${authFunName})`,
228
+ `Authorization function returned a successful response: (λ: ${authFunName})`,
118
229
  )
119
230
 
120
- return Boom.forbidden('No principalId set on the Response')
121
- }
231
+ const authorizer = {
232
+ integrationLatency: '42',
233
+ principalId: result.principalId,
234
+ ...result.context,
235
+ }
122
236
 
123
- if (!authCanExecuteResource(result.policyDocument, event.methodArn)) {
237
+ // Set the credentials for the rest of the pipeline
238
+ return h.authenticated({
239
+ credentials: {
240
+ authorizer,
241
+ context: result.context,
242
+ principalId: result.principalId,
243
+ usageIdentifierKey: result.usageIdentifierKey,
244
+ },
245
+ })
246
+ } catch {
124
247
  log.notice(
125
- `Authorization response didn't authorize user to access resource: (λ: ${authFunName})`,
248
+ `Authorization function returned an error response: (λ: ${authFunName})`,
126
249
  )
127
250
 
128
- return Boom.forbidden(
129
- 'User is not authorized to access this resource',
130
- )
251
+ return Boom.unauthorized('Unauthorized')
131
252
  }
253
+ },
254
+ })
255
+ }
132
256
 
133
- // validate the resulting context, ensuring that all
134
- // values are either string, number, or boolean types
135
- if (result.context) {
136
- const validationResult = authValidateContext(
137
- result.context,
138
- authFunName,
139
- )
257
+ const checkForIdentitySourceMatch = (exp, expectedLength) => {
258
+ const identitySourceMatch = exp.exec(authorizerOptions.identitySource)
140
259
 
141
- if (validationResult instanceof Error) {
142
- return validationResult
143
- }
260
+ if (!identitySourceMatch || identitySourceMatch.length !== expectedLength) {
261
+ return undefined
262
+ }
263
+ return identitySourceMatch[expectedLength - 1]
264
+ }
144
265
 
145
- result.context = validationResult
146
- }
266
+ if (
267
+ authorizerOptions.type !== 'request' ||
268
+ authorizerOptions.identitySource
269
+ ) {
270
+ const headerRegExp = /^(method.|\$)request.header.((?:\w+-?)+\w+)$/
271
+ const queryStringRegExp =
272
+ /^(method.|\$)request.querystring.((?:\w+-?)+\w+)$/
147
273
 
148
- log.notice(
149
- `Authorization function returned a successful response: (λ: ${authFunName})`,
150
- )
274
+ const identityHeaderResult = checkForIdentitySourceMatch(headerRegExp, 3)
275
+ if (identityHeaderResult !== undefined) {
276
+ identitySourceField = identityHeaderResult.toLowerCase()
277
+ identitySourceType = IDENTITY_SOURCE_TYPE_HEADER
278
+ return finalizeAuthScheme()
279
+ }
151
280
 
152
- const authorizer = {
153
- integrationLatency: '42',
154
- principalId: result.principalId,
155
- ...result.context,
156
- }
281
+ const identityQueryStringResult = checkForIdentitySourceMatch(
282
+ queryStringRegExp,
283
+ 3,
284
+ )
285
+ if (identityQueryStringResult !== undefined) {
286
+ identitySourceField = identityQueryStringResult
287
+ identitySourceType = IDENTITY_SOURCE_TYPE_QUERYSTRING
288
+ return finalizeAuthScheme()
289
+ }
157
290
 
158
- // Set the credentials for the rest of the pipeline
159
- return h.authenticated({
160
- credentials: {
161
- authorizer,
162
- context: result.context,
163
- principalId: result.principalId,
164
- usageIdentifierKey: result.usageIdentifierKey,
165
- },
166
- })
167
- } catch {
168
- log.notice(
169
- `Authorization function returned an error response: (λ: ${authFunName})`,
170
- )
291
+ throw new Error(
292
+ `Serverless Offline only supports retrieving tokens from headers and querystring parameters (λ: ${authFunName})`,
293
+ )
294
+ }
171
295
 
172
- return Boom.unauthorized('Unauthorized')
173
- }
174
- },
175
- })
296
+ return finalizeAuthScheme()
176
297
  }
@@ -0,0 +1,11 @@
1
+ import parseQueryStringParameters from './parseQueryStringParameters.js'
2
+
3
+ export default function getRawQueryParams(url) {
4
+ const queryParams = parseQueryStringParameters(url) || {}
5
+ return Object.keys(queryParams)
6
+ .reduce(function reducer(accumulator, currentKey) {
7
+ accumulator.push(`${currentKey}=${queryParams[currentKey]}`)
8
+ return accumulator
9
+ }, [])
10
+ .join('&')
11
+ }
@@ -7,6 +7,7 @@ export { default as formatToClfTime } from './formatToClfTime.js'
7
7
  export { default as generateHapiPath } from './generateHapiPath.js'
8
8
  export { default as getApiKeysValues } from './getApiKeysValues.js'
9
9
  export { default as getHttpApiCorsConfig } from './getHttpApiCorsConfig.js'
10
+ export { default as getRawQueryParams } from './getRawQueryParams.js'
10
11
  export { default as jsonPath } from './jsonPath.js'
11
12
  export { default as lowerCaseKeys } from './lowerCaseKeys.js'
12
13
  export { default as parseHeaders } from './parseHeaders.js'