diff --git a/src/events/http/createAuthScheme.js b/src/events/http/createAuthScheme.js index 186055495..01c48234d 100644 --- a/src/events/http/createAuthScheme.js +++ b/src/events/http/createAuthScheme.js @@ -11,239 +11,287 @@ import { parseQueryStringParameters, } from '../../utils/index.js' +const IDENTITY_SOURCE_TYPE_HEADER = 'header' +const IDENTITY_SOURCE_TYPE_QUERYSTRING = 'querystring' + export default function createAuthScheme(authorizerOptions, provider, lambda) { const authFunName = authorizerOptions.name - let identityHeader = 'authorization' + let identitySourceField = 'authorization' + let identitySourceType = IDENTITY_SOURCE_TYPE_HEADER - if ( - authorizerOptions.type !== 'request' || - authorizerOptions.identitySource - ) { - const identitySourceMatch = - /^(method.|\$)request.header.((?:\w+-?)+\w+)$/.exec( - authorizerOptions.identitySource, - ) - - if (!identitySourceMatch || identitySourceMatch.length !== 3) { - throw new Error( - `Serverless Offline only supports retrieving tokens from headers (λ: ${authFunName})`, - ) - } + const finalizeAuthScheme = () => { + return () => ({ + async authenticate(request, h) { + log.notice() + log.notice( + `Running Authorization function for ${request.method} ${request.path} (λ: ${authFunName})`, + ) - identityHeader = identitySourceMatch[2].toLowerCase() - } + const { rawHeaders, url } = request.raw.req + + // Get path params + // aws doesn't auto decode path params - hapi does + const pathParams = { ...request.params } - // Create Auth Scheme - return () => ({ - async authenticate(request, h) { - log.notice() - log.notice( - `Running Authorization function for ${request.method} ${request.path} (λ: ${authFunName})`, - ) - - const { rawHeaders, url } = request.raw.req - - // Get path params - // aws doesn't auto decode path params - hapi does - const pathParams = { ...request.params } - - 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}`), - '', - ) - - let event = { - enhancedAuthContext: {}, - headers: parseHeaders(rawHeaders), - requestContext: { - accountId, - apiId, - domainName: `${apiId}.execute-api.us-east-1.amazonaws.com`, - domainPrefix: apiId, - requestId, - stage: provider.stage, - }, - version: authorizerOptions.payloadVersion, - } - - 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, - authorizationToken: finalAuthorization, - httpMethod: request.method.toUpperCase(), - identitySource: finalAuthorization, - methodArn, - multiValueHeaders: parseMultiValueHeaders(rawHeaders), - multiValueQueryStringParameters: - parseMultiValueQueryStringParameters(url), - path: request.path, - pathParameters: nullIfEmpty(pathParams), - queryStringParameters: parseQueryStringParameters(url), + 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}`), + '', + ) + + let event = { + enhancedAuthContext: {}, + headers: parseHeaders(rawHeaders), requestContext: { - extendedRequestId: requestId, - httpMethod, - path: request.path, - protocol, - requestTime: currentDate.toString(), - requestTimeEpoch: currentDate.getTime(), - resourceId, - resourcePath, + accountId, + apiId, + domainName: `${apiId}.execute-api.us-east-1.amazonaws.com`, + domainPrefix: apiId, + requestId, stage: provider.stage, }, - resource: resourcePath, + version: authorizerOptions.payloadVersion, } - } - - if (authorizerOptions.payloadVersion === '2.0') { - event = { - ...event, - identitySource: [finalAuthorization], - rawPath: request.path, - rawQueryString: getRawQueryParams(url), - requestContext: { - http: { - method: httpMethod, - path: resourcePath, + + 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}` + + let authorization + if (identitySourceType === IDENTITY_SOURCE_TYPE_HEADER) { + const headers = request.raw.req.headers ?? {} + authorization = headers[identitySourceField] + } else if (identitySourceType === IDENTITY_SOURCE_TYPE_QUERYSTRING) { + const queryStringParameters = parseQueryStringParameters(url) ?? {} + authorization = queryStringParameters[identitySourceField] + } else { + throw new Error( + `No Authorization source has been specified. This should never happen. (λ: ${authFunName})`, + ) + } + + if (authorization === undefined) { + throw new Error( + `Identity Source is null for ${identitySourceType} ${identitySourceField} (λ: ${authFunName})`, + ) + } + + const identityValidationExpression = new RegExp( + authorizerOptions.identityValidationExpression, + ) + const matchedAuthorization = + identityValidationExpression.test(authorization) + const finalAuthorization = matchedAuthorization ? authorization : '' + + log.debug( + `Retrieved ${identitySourceField} ${identitySourceType} "${finalAuthorization}"`, + ) + + if (authorizerOptions.payloadVersion === '1.0') { + event = { + ...event, + authorizationToken: finalAuthorization, + httpMethod: request.method.toUpperCase(), + identitySource: finalAuthorization, + methodArn, + multiValueHeaders: parseMultiValueHeaders(rawHeaders), + multiValueQueryStringParameters: + parseMultiValueQueryStringParameters(url), + path: request.path, + pathParameters: nullIfEmpty(pathParams), + queryStringParameters: parseQueryStringParameters(url), + requestContext: { + extendedRequestId: requestId, + httpMethod, + path: request.path, protocol, + requestTime: currentDate.toString(), + requestTimeEpoch: currentDate.getTime(), + resourceId, + resourcePath, + stage: provider.stage, }, - routeKey: resourceId, - time: currentDate.toString(), - timeEpoch: currentDate.getTime(), - }, - routeArn: methodArn, - routeKey: resourceId, + resource: resourcePath, + } } - } - - // 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', + + 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, + } } - } else { - // This is safe since type: 'TOKEN' cannot have payload format 2.0 - event = { - ...event, - type: 'TOKEN', + + // 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, + type: 'TOKEN', + } } - } - const lambdaFunction = lambda.get(authFunName) - lambdaFunction.setEvent(event) + const lambdaFunction = lambda.get(authFunName) + lambdaFunction.setEvent(event) + + 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') - try { - const result = await lambdaFunction.runHandler() + // Validate that the policy document has the principalId set + if (!result.principalId) { + log.notice( + `Authorization response did not include a principalId: (λ: ${authFunName})`, + ) - if (authorizerOptions.enableSimpleResponses) { - if (result.isAuthorized) { - const authorizer = { - integrationLatency: '42', - ...result.context, - } - return h.authenticated({ - credentials: { - authorizer, - context: result.context || {}, - }, - }) + return Boom.forbidden('No principalId set on the Response') } - return Boom.forbidden( - 'User is not authorized to access this resource', - ) - } - if (result === 'Unauthorized') return Boom.unauthorized('Unauthorized') + if ( + !authCanExecuteResource( + result.policyDocument, + event.methodArn || event.routeArn, + ) + ) { + log.notice( + `Authorization response didn't authorize user to access resource: (λ: ${authFunName})`, + ) + + return Boom.forbidden( + 'User is not authorized to access this resource', + ) + } + + // validate the resulting context, ensuring that all + // values are either string, number, or boolean types + if (result.context) { + const validationResult = authValidateContext( + result.context, + authFunName, + ) + + if (validationResult instanceof Error) { + return validationResult + } + + result.context = validationResult + } - // Validate that the policy document has the principalId set - if (!result.principalId) { log.notice( - `Authorization response did not include a principalId: (λ: ${authFunName})`, + `Authorization function returned a successful response: (λ: ${authFunName})`, ) - return Boom.forbidden('No principalId set on the Response') - } + const authorizer = { + integrationLatency: '42', + principalId: result.principalId, + ...result.context, + } - if ( - !authCanExecuteResource( - result.policyDocument, - event.methodArn || event.routeArn, - ) - ) { + // Set the credentials for the rest of the pipeline + return h.authenticated({ + credentials: { + authorizer, + context: result.context, + principalId: result.principalId, + usageIdentifierKey: result.usageIdentifierKey, + }, + }) + } catch { log.notice( - `Authorization response didn't authorize user to access resource: (λ: ${authFunName})`, + `Authorization function returned an error response: (λ: ${authFunName})`, ) - return Boom.forbidden( - 'User is not authorized to access this resource', - ) + return Boom.unauthorized('Unauthorized') } + }, + }) + } - // validate the resulting context, ensuring that all - // values are either string, number, or boolean types - if (result.context) { - const validationResult = authValidateContext( - result.context, - authFunName, - ) - - if (validationResult instanceof Error) { - return validationResult - } + const checkForIdentitySourceMatch = (exp, expectedLength) => { + const identitySourceMatch = exp.exec(authorizerOptions.identitySource) - result.context = validationResult - } + if (!identitySourceMatch || identitySourceMatch.length !== expectedLength) { + return undefined + } + return identitySourceMatch[expectedLength - 1] + } - log.notice( - `Authorization function returned a successful response: (λ: ${authFunName})`, - ) + if ( + authorizerOptions.type !== 'request' || + authorizerOptions.identitySource + ) { + const headerRegExp = /^(method.|\$)request.header.((?:\w+-?)+\w+)$/ + const queryStringRegExp = + /^(method.|\$)request.querystring.((?:\w+-?)+\w+)$/ + + const identityHeaderResult = checkForIdentitySourceMatch(headerRegExp, 3) + if (identityHeaderResult !== undefined) { + identitySourceField = identityHeaderResult.toLowerCase() + identitySourceType = IDENTITY_SOURCE_TYPE_HEADER + return finalizeAuthScheme() + } - const authorizer = { - integrationLatency: '42', - principalId: result.principalId, - ...result.context, - } + const identityQueryStringResult = checkForIdentitySourceMatch( + queryStringRegExp, + 3, + ) + if (identityQueryStringResult !== undefined) { + identitySourceField = identityQueryStringResult + identitySourceType = IDENTITY_SOURCE_TYPE_QUERYSTRING + return finalizeAuthScheme() + } - // Set the credentials for the rest of the pipeline - return h.authenticated({ - credentials: { - authorizer, - context: result.context, - principalId: result.principalId, - usageIdentifierKey: result.usageIdentifierKey, - }, - }) - } catch { - log.notice( - `Authorization function returned an error response: (λ: ${authFunName})`, - ) + throw new Error( + `Serverless Offline only supports retrieving tokens from headers and querystring parameters (λ: ${authFunName})`, + ) + } - return Boom.unauthorized('Unauthorized') - } - }, - }) + return finalizeAuthScheme() } diff --git a/tests/integration/request-authorizer/request-authorizer.test.js b/tests/integration/request-authorizer/request-authorizer.test.js index 4f890516c..dae1bd743 100644 --- a/tests/integration/request-authorizer/request-authorizer.test.js +++ b/tests/integration/request-authorizer/request-authorizer.test.js @@ -31,7 +31,7 @@ describe('request authorizer tests', () => { }) } - describe('authorizer with payload format 1.0', () => { + describe('authorizer with payload format 1.0 and header identity source', () => { ;[ { description: 'should respond with Allow policy', @@ -43,7 +43,7 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', }, }, - path: '/user1', + path: '/user1-header', status: 200, }, @@ -59,7 +59,7 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', }, }, - path: '/user1', + path: '/user1-header', status: 403, }, @@ -75,13 +75,51 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', }, }, - path: '/user1', + path: '/user1-header', status: 401, }, ].forEach(doTest) }) - describe('authorizer with payload format 2.0', () => { + describe('authorizer with payload format 1.0 and querystring identity source', () => { + ;[ + { + description: 'should respond with Allow policy', + expected: { + status: 'Authorized', + }, + options: {}, + path: '/user1-querystring?query1=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + status: 200, + }, + + { + description: 'should respond with Deny policy', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: {}, + path: '/user1-querystring?query1=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: {}, + path: '/user1-querystring?query1=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', + status: 401, + }, + ].forEach(doTest) + }) + + describe('authorizer with payload format 2.0 and header identity source', () => { ;[ { description: 'should respond with Allow policy', @@ -93,7 +131,7 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', }, }, - path: '/user2', + path: '/user2-header', status: 200, }, @@ -109,7 +147,7 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', }, }, - path: '/user2', + path: '/user2-header', status: 403, }, @@ -125,13 +163,51 @@ describe('request authorizer tests', () => { Authorization: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', }, }, - path: '/user2', + path: '/user2-header', status: 401, }, ].forEach(doTest) }) - describe('authorizer with payload format 2.0 with simple responses enabled', () => { + describe('authorizer with payload format 2.0 and querystring identity source', () => { + ;[ + { + description: 'should respond with Allow policy', + expected: { + status: 'Authorized', + }, + options: {}, + path: '/user2-querystring?query2=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + status: 200, + }, + + { + description: 'should respond with Deny policy', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: {}, + path: '/user2-querystring?query2=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: {}, + path: '/user2-querystring?query2=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', + status: 401, + }, + ].forEach(doTest) + }) + + describe('authorizer with payload format 2.0 with simple responses enabled and header identity source', () => { ;[ { description: 'should respond with isAuthorized true', @@ -143,7 +219,7 @@ describe('request authorizer tests', () => { AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', }, }, - path: '/user2simple', + path: '/user2simple-header', status: 200, }, @@ -159,7 +235,7 @@ describe('request authorizer tests', () => { AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', }, }, - path: '/user2simple', + path: '/user2simple-header', status: 403, }, @@ -175,7 +251,45 @@ describe('request authorizer tests', () => { AuthorizationSimple: 'Bearer fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', }, }, - path: '/user2simple', + path: '/user2simple-header', + status: 401, + }, + ].forEach(doTest) + }) + + describe('authorizer with payload format 2.0 with simple responses enabled and querystring identity source', () => { + ;[ + { + description: 'should respond with Allow policy', + expected: { + status: 'Authorized', + }, + options: {}, + path: '/user2simple-querystring?query2simple=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a', + status: 200, + }, + + { + description: 'should respond with Deny policy', + expected: { + error: 'Forbidden', + message: 'User is not authorized to access this resource', + statusCode: 403, + }, + options: {}, + path: '/user2simple-querystring?query2simple=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5b', + status: 403, + }, + + { + description: 'should fail with an Unauthorized error', + expected: { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401, + }, + options: {}, + path: '/user2simple-querystring?query2simple=fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5c', status: 401, }, ].forEach(doTest) diff --git a/tests/integration/request-authorizer/serverless.yml b/tests/integration/request-authorizer/serverless.yml index 70f2062f6..53ffe9d77 100644 --- a/tests/integration/request-authorizer/serverless.yml +++ b/tests/integration/request-authorizer/serverless.yml @@ -11,24 +11,43 @@ provider: deploymentMethod: direct httpApi: authorizers: - requestAuthorizer1Format: + requestAuthorizer1FormatHeader: functionName: requestAuthorizer1Format identitySource: $request.header.Authorization payloadVersion: '1.0' type: request - requestAuthorizer2Format: + requestAuthorizer2FormatHeader: functionName: requestAuthorizer2Format identitySource: $request.header.Authorization payloadVersion: '2.0' type: request - requestAuthorizer2FormatSimple: + requestAuthorizer2FormatSimpleHeader: enableSimpleResponses: true functionName: requestAuthorizer2FormatSimple identitySource: $request.header.AuthorizationSimple payloadVersion: '2.0' type: request + + requestAuthorizer1FormatQueryString: + functionName: requestAuthorizer1FormatQueryString + identitySource: $request.querystring.query1 + payloadVersion: '1.0' + type: request + + requestAuthorizer2FormatQueryString: + functionName: requestAuthorizer2FormatQueryString + identitySource: $request.querystring.query2 + payloadVersion: '2.0' + type: request + + requestAuthorizer2FormatSimpleQueryString: + enableSimpleResponses: true + functionName: requestAuthorizer2FormatSimpleQueryString + identitySource: $request.querystring.query2simple + payloadVersion: '2.0' + type: request memorySize: 1024 name: aws region: us-east-1 # default @@ -41,34 +60,70 @@ functions: events: - httpApi: authorizer: - name: requestAuthorizer1Format + name: requestAuthorizer1FormatHeader method: get - path: /user1 + path: /user1-header handler: src/handler.user user2: events: - httpApi: authorizer: - name: requestAuthorizer2Format + name: requestAuthorizer2FormatHeader method: get - path: /user2 + path: /user2-header handler: src/handler.user user2simple: events: - httpApi: authorizer: - name: requestAuthorizer2FormatSimple + name: requestAuthorizer2FormatSimpleHeader + method: get + path: /user2simple-header + handler: src/handler.user + + user1WithQueryString: + events: + - httpApi: + authorizer: + name: requestAuthorizer1FormatQueryString + method: get + path: /user1-querystring + handler: src/handler.user + + user2WithQueryString: + events: + - httpApi: + authorizer: + name: requestAuthorizer2FormatQueryString method: get - path: /user2simple + path: /user2-querystring handler: src/handler.user - requestAuthorizer1Format: + user2simpleWithQueryString: + events: + - httpApi: + authorizer: + name: requestAuthorizer2FormatSimpleQueryString + method: get + path: /user2simple-querystring + handler: src/handler.user + + requestAuthorizer1FormatHeader: + handler: src/authorizer.requestAuthorizer1Format + + requestAuthorizer2FormatHeader: + handler: src/authorizer.requestAuthorizer2Format + + requestAuthorizer2FormatSimpleHeader: + handler: src/authorizer.requestAuthorizer2FormatSimple + + requestAuthorizer1FormatQueryString: handler: src/authorizer.requestAuthorizer1Format - requestAuthorizer2Format: + requestAuthorizer2FormatQueryString: handler: src/authorizer.requestAuthorizer2Format - requestAuthorizer2FormatSimple: + requestAuthorizer2FormatSimpleQueryString: handler: src/authorizer.requestAuthorizer2FormatSimple diff --git a/tests/integration/request-authorizer/src/authorizer.js b/tests/integration/request-authorizer/src/authorizer.js index bf57e8871..47cbe5ada 100644 --- a/tests/integration/request-authorizer/src/authorizer.js +++ b/tests/integration/request-authorizer/src/authorizer.js @@ -24,9 +24,18 @@ function generateSimpleResponse(authorizedValue) { } } +function parseIdentitySourceToken(source) { + if (source.includes('Bearer')) { + const [, credential] = source.split(' ') + return credential + } + + return source +} + // On version 1.0, identitySource is a string export async function requestAuthorizer1Format(event) { - const [, credential] = event.identitySource.split(' ') + const credential = parseIdentitySourceToken(event.identitySource) if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { return generatePolicy('user123', 'Allow', event.methodArn) @@ -41,7 +50,7 @@ export async function requestAuthorizer1Format(event) { // On version 2.0, identitySource is a string array export async function requestAuthorizer2Format(event) { - const [, credential] = event.identitySource[0].split(' ') + const credential = parseIdentitySourceToken(event.identitySource[0]) if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { return generatePolicy('user123', 'Allow', event.routeArn) @@ -58,7 +67,7 @@ export async function requestAuthorizer2Format(event) { // 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(' ') + const credential = parseIdentitySourceToken(event.identitySource[0]) if (credential === 'fc3e55ea-e6ec-4bf2-94d2-06ae6efe6e5a') { return generateSimpleResponse(true)