From ec0d31c07b10bd015707b5f984229e1e35e5a8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alvaro=20L=C3=B3pez?= Date: Mon, 7 Nov 2022 20:55:19 -0500 Subject: [PATCH] feat: httpApi with request authorizer (#1600) --- src/events/http/HttpServer.js | 41 +++- src/events/http/createAuthScheme.js | 143 ++++++++++---- src/utils/getRawQueryParams.js | 11 ++ src/utils/index.js | 1 + .../integration/authorizer/authorizer.test.js | 2 +- .../integration/authorizer/src/authorizer.js | 2 +- .../request-authorizer.test.js | 183 ++++++++++++++++++ .../request-authorizer/serverless.yml | 72 +++++++ .../request-authorizer/src/authorizer.js | 72 +++++++ .../request-authorizer/src/handler.js | 8 + .../request-authorizer/src/package.json | 3 + 11 files changed, 498 insertions(+), 40 deletions(-) create mode 100644 src/utils/getRawQueryParams.js create mode 100644 tests/integration/request-authorizer/request-authorizer.test.js create mode 100644 tests/integration/request-authorizer/serverless.yml create mode 100644 tests/integration/request-authorizer/src/authorizer.js create mode 100644 tests/integration/request-authorizer/src/handler.js create mode 100644 tests/integration/request-authorizer/src/package.json diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index fe8076381..54dd30d14 100644 --- a/src/events/http/HttpServer.js +++ b/src/events/http/HttpServer.js @@ -253,6 +253,16 @@ export default class HttpServer { return null } + if ( + (endpoint.authorizer.name && + this.#serverless.service.provider?.httpApi?.authorizers?.[ + endpoint.authorizer.name + ]?.type === 'request') || + endpoint.authorizer.type === 'request' + ) { + return null + } + const jwtSettings = this.#extractJWTAuthSettings(endpoint) if (!jwtSettings) { return null @@ -303,11 +313,36 @@ export default class HttpServer { log.error(`Authorization function ${authFunctionName} does not exist`) return null } + const serverlessAuthorizerOptions = + this.#serverless.service.provider.httpApi && + this.#serverless.service.provider.httpApi.authorizers && + this.#serverless.service.provider.httpApi.authorizers[authFunctionName] const authorizerOptions = { - identitySource: 'method.request.header.Authorization', - identityValidationExpression: '(.*)', - resultTtlInSeconds: '300', + enableSimpleResponses: + (endpoint.isHttpApi && + serverlessAuthorizerOptions?.enableSimpleResponses) || + false, + identitySource: + serverlessAuthorizerOptions?.identitySource || + 'method.request.header.Authorization', + identityValidationExpression: + serverlessAuthorizerOptions?.identityValidationExpression || '(.*)', + payloadVersion: !endpoint.isHttpApi + ? '1.0' + : serverlessAuthorizerOptions?.payloadVersion || '2.0', + resultTtlInSeconds: + serverlessAuthorizerOptions?.resultTtlInSeconds || '300', + } + + if ( + authorizerOptions.enableSimpleResponses && + authorizerOptions.payloadVersion === '1.0' + ) { + log.error( + `Cannot create Authorization function '${authFunctionName}' if payloadVersion is '1.0' and enableSimpleResponses is true`, + ) + return null } if (typeof endpoint.authorizer === 'string') { diff --git a/src/events/http/createAuthScheme.js b/src/events/http/createAuthScheme.js index b11a87dce..186055495 100644 --- a/src/events/http/createAuthScheme.js +++ b/src/events/http/createAuthScheme.js @@ -3,6 +3,7 @@ import { log } from '@serverless/utils/log.js' import authCanExecuteResource from '../authCanExecuteResource.js' import authValidateContext from '../authValidateContext.js' import { + getRawQueryParams, nullIfEmpty, parseHeaders, parseMultiValueHeaders, @@ -14,18 +15,22 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { const authFunName = authorizerOptions.name let identityHeader = 'authorization' - if (authorizerOptions.type !== 'request') { - const identitySourceMatch = /^method.request.header.((?:\w+-?)+\w+)$/.exec( - authorizerOptions.identitySource, - ) + if ( + authorizerOptions.type !== 'request' || + authorizerOptions.identitySource + ) { + const identitySourceMatch = + /^(method.|\$)request.header.((?:\w+-?)+\w+)$/.exec( + authorizerOptions.identitySource, + ) - if (!identitySourceMatch || identitySourceMatch.length !== 2) { + if (!identitySourceMatch || identitySourceMatch.length !== 3) { throw new Error( - `Serverless Offline only supports retrieving tokens from the headers (λ: ${authFunName})`, + `Serverless Offline only supports retrieving tokens from headers (λ: ${authFunName})`, ) } - identityHeader = identitySourceMatch[1].toLowerCase() + identityHeader = identitySourceMatch[2].toLowerCase() } // Create Auth Scheme @@ -36,8 +41,7 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { `Running Authorization function for ${request.method} ${request.path} (λ: ${authFunName})`, ) - // Get Authorization header - const { req } = request.raw + const { rawHeaders, url } = request.raw.req // Get path params // aws doesn't auto decode path params - hapi does @@ -45,6 +49,8 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { const accountId = 'random-account-id' const apiId = 'random-api-id' + const requestId = 'random-request-id' + const httpMethod = request.method.toUpperCase() const resourcePath = request.route.path.replace( new RegExp(`^/${provider.stage}`), @@ -53,53 +59,96 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { let event = { enhancedAuthContext: {}, - methodArn: `arn:aws:execute-api:${provider.region}:${accountId}:${apiId}/${provider.stage}/${httpMethod}${resourcePath}`, + headers: parseHeaders(rawHeaders), requestContext: { accountId, apiId, - httpMethod, - path: request.path, - requestId: 'random-request-id', - resourceId: 'random-resource-id', - resourcePath, + domainName: `${apiId}.execute-api.us-east-1.amazonaws.com`, + domainPrefix: apiId, + requestId, stage: provider.stage, }, - resource: resourcePath, + version: authorizerOptions.payloadVersion, } - // Create event Object for authFunction - // methodArn is the ARN of the function we are running we are authorizing access to (or not) - // Account ID and API ID are not simulated - if (authorizerOptions.type === 'request') { - const { rawHeaders, url } = req + const protocol = `${request.server.info.protocol.toUpperCase()}/${ + request.raw.req.httpVersion + }` + const currentDate = new Date() + const resourceId = `${httpMethod} ${resourcePath}` + const methodArn = `arn:aws:execute-api:${provider.region}:${accountId}:${apiId}/${provider.stage}/${httpMethod}${resourcePath}` + + const authorization = request.raw.req.headers[identityHeader] + + const identityValidationExpression = new RegExp( + authorizerOptions.identityValidationExpression, + ) + const matchedAuthorization = + identityValidationExpression.test(authorization) + const finalAuthorization = matchedAuthorization ? authorization : '' + + log.debug(`Retrieved ${identityHeader} header "${finalAuthorization}"`) + if (authorizerOptions.payloadVersion === '1.0') { event = { ...event, - headers: parseHeaders(rawHeaders), + authorizationToken: finalAuthorization, httpMethod: request.method.toUpperCase(), + identitySource: finalAuthorization, + methodArn, multiValueHeaders: parseMultiValueHeaders(rawHeaders), multiValueQueryStringParameters: parseMultiValueQueryStringParameters(url), path: request.path, pathParameters: nullIfEmpty(pathParams), queryStringParameters: parseQueryStringParameters(url), - type: 'REQUEST', + requestContext: { + extendedRequestId: requestId, + httpMethod, + path: request.path, + protocol, + requestTime: currentDate.toString(), + requestTimeEpoch: currentDate.getTime(), + resourceId, + resourcePath, + stage: provider.stage, + }, + resource: resourcePath, } - } else { - const authorization = req.headers[identityHeader] - - const identityValidationExpression = new RegExp( - authorizerOptions.identityValidationExpression, - ) - const matchedAuthorization = - identityValidationExpression.test(authorization) - const finalAuthorization = matchedAuthorization ? authorization : '' + } - log.debug(`Retrieved ${identityHeader} header "${finalAuthorization}"`) + if (authorizerOptions.payloadVersion === '2.0') { + event = { + ...event, + identitySource: [finalAuthorization], + rawPath: request.path, + rawQueryString: getRawQueryParams(url), + requestContext: { + http: { + method: httpMethod, + path: resourcePath, + protocol, + }, + routeKey: resourceId, + time: currentDate.toString(), + timeEpoch: currentDate.getTime(), + }, + routeArn: methodArn, + routeKey: resourceId, + } + } + // methodArn is the ARN of the function we are running we are authorizing access to (or not) + // Account ID and API ID are not simulated + if (authorizerOptions.type === 'request') { + event = { + ...event, + type: 'REQUEST', + } + } else { + // This is safe since type: 'TOKEN' cannot have payload format 2.0 event = { ...event, - authorizationToken: finalAuthorization, type: 'TOKEN', } } @@ -109,6 +158,25 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { try { const result = await lambdaFunction.runHandler() + + if (authorizerOptions.enableSimpleResponses) { + if (result.isAuthorized) { + const authorizer = { + integrationLatency: '42', + ...result.context, + } + return h.authenticated({ + credentials: { + authorizer, + context: result.context || {}, + }, + }) + } + return Boom.forbidden( + 'User is not authorized to access this resource', + ) + } + if (result === 'Unauthorized') return Boom.unauthorized('Unauthorized') // Validate that the policy document has the principalId set @@ -120,7 +188,12 @@ export default function createAuthScheme(authorizerOptions, provider, lambda) { return Boom.forbidden('No principalId set on the Response') } - if (!authCanExecuteResource(result.policyDocument, event.methodArn)) { + if ( + !authCanExecuteResource( + result.policyDocument, + event.methodArn || event.routeArn, + ) + ) { log.notice( `Authorization response didn't authorize user to access resource: (λ: ${authFunName})`, ) diff --git a/src/utils/getRawQueryParams.js b/src/utils/getRawQueryParams.js new file mode 100644 index 000000000..d3759280c --- /dev/null +++ b/src/utils/getRawQueryParams.js @@ -0,0 +1,11 @@ +import parseQueryStringParameters from './parseQueryStringParameters.js' + +export default function getRawQueryParams(url) { + const queryParams = parseQueryStringParameters(url) || {} + return Object.keys(queryParams) + .reduce(function reducer(accumulator, currentKey) { + accumulator.push(`${currentKey}=${queryParams[currentKey]}`) + return accumulator + }, []) + .join('&') +} diff --git a/src/utils/index.js b/src/utils/index.js index 4002e92a8..04569704f 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -7,6 +7,7 @@ export { default as formatToClfTime } from './formatToClfTime.js' export { default as generateHapiPath } from './generateHapiPath.js' export { default as getApiKeysValues } from './getApiKeysValues.js' export { default as getHttpApiCorsConfig } from './getHttpApiCorsConfig.js' +export { default as getRawQueryParams } from './getRawQueryParams.js' export { default as jsonPath } from './jsonPath.js' export { default as lowerCaseKeys } from './lowerCaseKeys.js' export { default as parseHeaders } from './parseHeaders.js' diff --git a/tests/integration/authorizer/authorizer.test.js b/tests/integration/authorizer/authorizer.test.js index 41c58c0ff..077ae4308 100644 --- a/tests/integration/authorizer/authorizer.test.js +++ b/tests/integration/authorizer/authorizer.test.js @@ -9,7 +9,7 @@ import { setup, teardown } from '../../_testHelpers/index.js' const __dirname = dirname(fileURLToPath(import.meta.url)) -describe('authorizer tests', function desc() { +describe('lalala authorizer tests', function desc() { beforeEach(() => setup({ servicePath: resolve(__dirname), diff --git a/tests/integration/authorizer/src/authorizer.js b/tests/integration/authorizer/src/authorizer.js index 018880548..db8031f9d 100644 --- a/tests/integration/authorizer/src/authorizer.js +++ b/tests/integration/authorizer/src/authorizer.js @@ -25,7 +25,7 @@ function generatePolicyWithContext(event, context) { return generatePolicy('user123', 'Allow', event.methodArn, context) } -export async function authorizerCallback(event, context, callback) { +export function authorizerCallback(event, context, callback) { const [, /* type */ credential] = event.authorizationToken.split(' ') if (credential === '4674cc54-bd05-11e7-abc4-cec278b6b50a') { diff --git a/tests/integration/request-authorizer/request-authorizer.test.js b/tests/integration/request-authorizer/request-authorizer.test.js new file mode 100644 index 000000000..4f890516c --- /dev/null +++ b/tests/integration/request-authorizer/request-authorizer.test.js @@ -0,0 +1,183 @@ +// tests based on: +// https://dev.to/piczmar_0/serverless-authorizers---custom-rest-authorizer-16 + +import assert from 'node:assert' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { BASE_URL } from '../../config.js' +import { setup, teardown } from '../../_testHelpers/index.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +describe('request authorizer tests', () => { + beforeEach(() => + setup({ + servicePath: resolve(__dirname), + }), + ) + + afterEach(() => teardown()) + + function doTest(params) { + const { description, expected, options, path, status } = params + it(description, async () => { + const url = new URL(path, BASE_URL) + + const response = await fetch(url, options) + assert.equal(response.status, status) + + const json = await response.json() + assert.deepEqual(json, expected) + }) + } + + describe('authorizer with payload format 1.0', () => { + ;[ + { + description: 'should respond with Allow policy', + expected: { + status: 'Authorized', + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + }, + }, + path: '/user1', + status: 200, + }, + + { + description: 'should respond with Deny policy', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + }, + }, + path: '/user1', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', + }, + }, + path: '/user1', + status: 401, + }, + ].forEach(doTest) + }) + + describe('authorizer with payload format 2.0', () => { + ;[ + { + description: 'should respond with Allow policy', + expected: { + status: 'Authorized', + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + }, + }, + path: '/user2', + status: 200, + }, + + { + description: 'should respond with Deny policy', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + }, + }, + path: '/user2', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: { + headers: { + Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', + }, + }, + path: '/user2', + status: 401, + }, + ].forEach(doTest) + }) + + describe('authorizer with payload format 2.0 with simple responses enabled', () => { + ;[ + { + description: 'should respond with isAuthorized true', + expected: { + status: 'Authorized', + }, + options: { + headers: { + AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + }, + }, + path: '/user2simple', + status: 200, + }, + + { + description: 'should respond with isAuthorized false', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: { + headers: { + AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + }, + }, + path: '/user2simple', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: { + headers: { + AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', + }, + }, + path: '/user2simple', + status: 401, + }, + ].forEach(doTest) + }) +}) diff --git a/tests/integration/request-authorizer/serverless.yml b/tests/integration/request-authorizer/serverless.yml new file mode 100644 index 000000000..cbaca07c9 --- /dev/null +++ b/tests/integration/request-authorizer/serverless.yml @@ -0,0 +1,72 @@ +service: request-authorizer + +configValidationMode: error +deprecationNotificationMode: error + +plugins: + - ../../../src/index.js + +provider: + memorySize: 128 + name: aws + region: us-east-1 # default + runtime: nodejs16.x + stage: dev + versionFunctions: false + httpApi: + authorizers: + requestAuthorizer1Format: + type: request + functionName: requestAuthorizer1Format + identitySource: $request.header.Authorization + payloadVersion: '1.0' + + requestAuthorizer2Format: + type: request + functionName: requestAuthorizer2Format + identitySource: $request.header.Authorization + payloadVersion: '2.0' + + requestAuthorizer2FormatSimple: + type: request + enableSimpleResponses: true + functionName: requestAuthorizer2FormatSimple + identitySource: $request.header.AuthorizationSimple + payloadVersion: '2.0' + +functions: + user1: + events: + - httpApi: + authorizer: + name: requestAuthorizer1Format + method: get + path: /user1 + handler: src/handler.user + + user2: + events: + - httpApi: + authorizer: + name: requestAuthorizer2Format + method: get + path: /user2 + handler: src/handler.user + + user2simple: + events: + - httpApi: + authorizer: + name: requestAuthorizer2FormatSimple + method: get + path: /user2simple + handler: src/handler.user + + requestAuthorizer1Format: + handler: src/authorizer.requestAuthorizer1Format + + requestAuthorizer2Format: + handler: src/authorizer.requestAuthorizer2Format + + requestAuthorizer2FormatSimple: + handler: src/authorizer.requestAuthorizer2FormatSimple diff --git a/tests/integration/request-authorizer/src/authorizer.js b/tests/integration/request-authorizer/src/authorizer.js new file mode 100644 index 000000000..bf57e8871 --- /dev/null +++ b/tests/integration/request-authorizer/src/authorizer.js @@ -0,0 +1,72 @@ +function generatePolicy(principalId, effect, resource) { + const authResponse = { + principalId, + } + + if (effect && resource) { + authResponse.policyDocument = { + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: effect, + Resource: resource, + }, + ], + Version: '2012-10-17', + } + } + return authResponse +} + +function generateSimpleResponse(authorizedValue) { + return { + isAuthorized: authorizedValue, + } +} + +// On version 1.0, identitySource is a string +export async function requestAuthorizer1Format(event) { + const [, credential] = event.identitySource.split(' ') + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { + return generatePolicy('user123', 'Allow', event.methodArn) + } + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b') { + return generatePolicy('user123', 'Deny', event.methodArn) + } + + throw new Error('Unauthorized') +} + +// On version 2.0, identitySource is a string array +export async function requestAuthorizer2Format(event) { + const [, credential] = event.identitySource[0].split(' ') + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { + return generatePolicy('user123', 'Allow', event.routeArn) + } + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b') { + return generatePolicy('user123', 'Deny', event.routeArn) + } + + throw new Error('Unauthorized') +} + +// On version 2.0, Simple responses do not require generating a policy. you can respond with a boolean object +// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html +// In this case, AWS doesn't care about the principal. +export async function requestAuthorizer2FormatSimple(event) { + const [, credential] = event.identitySource[0].split(' ') + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { + return generateSimpleResponse(true) + } + + if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b') { + return generateSimpleResponse(false) + } + + throw new Error('Unauthorized') +} diff --git a/tests/integration/request-authorizer/src/handler.js b/tests/integration/request-authorizer/src/handler.js new file mode 100644 index 000000000..d166b9259 --- /dev/null +++ b/tests/integration/request-authorizer/src/handler.js @@ -0,0 +1,8 @@ +const { stringify } = JSON + +export async function user() { + return { + body: stringify({ status: 'Authorized' }), + statusCode: 200, + } +} diff --git a/tests/integration/request-authorizer/src/package.json b/tests/integration/request-authorizer/src/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/integration/request-authorizer/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +}