Skip to content

Commit

Permalink
feat: httpApi with request authorizer (#1600)
Browse files Browse the repository at this point in the history
  • Loading branch information
rion18 authored Nov 8, 2022
1 parent a808ed2 commit ec0d31c
Show file tree
Hide file tree
Showing 11 changed files with 498 additions and 40 deletions.
41 changes: 38 additions & 3 deletions src/events/http/HttpServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand Down
143 changes: 108 additions & 35 deletions src/events/http/createAuthScheme.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -36,15 +41,16 @@ 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
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}`),
Expand All @@ -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',
}
}
Expand All @@ -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
Expand All @@ -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})`,
)
Expand Down
11 changes: 11 additions & 0 deletions src/utils/getRawQueryParams.js
Original file line number Diff line number Diff line change
@@ -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('&')
}
1 change: 1 addition & 0 deletions src/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/authorizer/authorizer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/authorizer/src/authorizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Loading

0 comments on commit ec0d31c

Please sign in to comment.