Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional JWT authorizer for HttpApi events #1022

Merged
merged 7 commits into from Jun 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -126,6 +126,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)",
Expand Down
14 changes: 12 additions & 2 deletions src/ServerlessOffline.js
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/config/commandOptions.js
Expand Up @@ -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.',
Expand Down
54 changes: 53 additions & 1 deletion src/events/http/HttpServer.js
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be better to throw an error or return failure result if the user specifies that the signature should not be ignored?
They may be unaware that the signature isn't being validated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't set ignoreJWTSignature you will have to set noAuth for serverless-offline to run at all for HttpApi events with authorization. The current behavior of serverless-offline is to treat authorization as another lambda. Since there isn't a function mapped to the authorizer, serverless-offline throws an error on startup. My intention with this flag is to make it explicit that while the authorization rules defined in the serverless.yml file are being tested, this is should not be considered valid security.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if you'd like to see the change you suggested. my intention was not to modify the current behavior of the library.

}

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
}
100 changes: 100 additions & 0 deletions 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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get a comment roughly describing what this does for the people who are terrible with regex (like myself)?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I struggle with regex too - here's the explanation from regex 101
/
^$request.header.((?:\w+-?)+\w+)$
/
^ asserts position at start of the string
$ matches the character $ literally (case sensitive)
request matches the characters request literally (case sensitive)
. matches any character (except for line terminators)
header matches the characters header literally (case sensitive)
. matches any character (except for line terminators)
1st Capturing Group ((?:\w+-?)+\w+)
Non-capturing group (?:\w+-?)+

  • Quantifier — Matches between one and unlimited times, as many times as possible, giving back as needed (greedy)
    \w+
    matches any word character (equal to [a-zA-Z0-9_])
    -?
    matches the character - literally (case sensitive)
    \w+
    matches any word character (equal to [a-zA-Z0-9_])
    $ asserts position at the end of the string, or before the line terminator right at the end of the string (if any)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect it's parsing the authorization header btw @chardos

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex is just copied from src/events/http/createAuthScheme.js

I did a little digging in the git history. Based on this commit:

e52cc2e

the regex was modified from just grabbing all characters (the w+ part) to also grabbing hyphens since those are valid characters in a request header.

This doesn't actually parse the auth header. It figures out what the name of the header key is if you have customized the header value that will actually contain the token. That header key is then used when the requests are coming in to grab the auth token from the request.

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(' ')

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using array destructuring is a clever way of leveraging the output of 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')
}
},
})
}
10 changes: 10 additions & 0 deletions src/events/http/lambda-events/LambdaProxyIntegrationEvent.js
Expand Up @@ -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
}
Expand Down Expand Up @@ -142,6 +151,7 @@ export default class LambdaProxyIntegrationEvent {
authAuthorizer ||
assign(authContext, {
claims,
scopes,
// 'principalId' should have higher priority
principalId:
authPrincipalId ||
Expand Down
14 changes: 14 additions & 0 deletions 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,
}
}