Skip to content

Commit

Permalink
Merge pull request #1022 from Ahana-Inc/add-jwt-authorizer
Browse files Browse the repository at this point in the history
Add optional JWT authorizer for HttpApi events
  • Loading branch information
dherault committed Jun 23, 2020
2 parents c4b6b9c + f8efd51 commit f893da3
Show file tree
Hide file tree
Showing 11 changed files with 515 additions and 5 deletions.
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 @@ -127,6 +127,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)
}

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

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,
}
}

0 comments on commit f893da3

Please sign in to comment.