diff --git a/.gitignore b/.gitignore index 60dc486..43e6930 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist .npmrc *.swp +.DS_Store diff --git a/README.md b/README.md index 95d3b00..c0d8051 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,68 @@ # OAuth 2.0 Bearer JWT Authorizer for AWS API Gateway -This project is sample implementation of an AWS Lambda custom authorizer for [AWS API Gateway](https://aws.amazon.com/api-gateway/) that works with a JWT bearer token (`id_token` or `access_token`) issued by an OAuth 2.0 Authorization Server. It can be used to secure access to APIs managed by [AWS API Gateway](https://aws.amazon.com/api-gateway/). +This project is a sample Node.js implementation of an AWS Lambda custom authorizer for [AWS API Gateway](https://aws.amazon.com/api-gateway/) that works with a JWT bearer token (`id_token` or `access_token`) issued by an OAuth 2.0 Authorization Server. It can be used to secure access to APIs managed by [AWS API Gateway](https://aws.amazon.com/api-gateway/). -## Configuration +It has been designed to work with Okta but should work with any OAuth 2.0 Authorization Server. -### Environment Variables (.env) +The authorizer uses Okta's [JWT Verifier library](https://github.com/okta/okta-oidc-js/tree/master/packages/jwt-verifier) to retrieve keys (jwks) from your Okta tenant and verify tokens. -Update the `ISSUER` and `AUDIENCE` variables in the `.env` file +## Prerequisites -``` -ISSUER=https://example.oktapreview.com/oauth2/aus8o56xh1qncrlwT0h7 -AUDIENCE=https://api.example.com -``` +There are three main components to this setup: -It is critical that the `issuer` and `audience` claims for JWT bearer tokens are [properly validated using best practices](http://www.cloudidentity.com/blog/2014/03/03/principles-of-token-validation/). You can obtain these values from your OAuth 2.0 Authorization Server configuration. +1. An Okta tenant, with an Authorization Server +If you do not already have an Okta tenant with an Authorization Server, you can get a free-forever developer tenant from Okta that has all the capabilities you need for this repo [here](https://developer.okta.com). -The `audience` value should uniquely identify your AWS API Gateway deployment. You should assign unique audiences for each API Gateway authorizer instance so that a token intended for one gateway is not valid for another. +2. Amazon API Gateway -### Signature Keys (keys.json) +3. An AWS Lambda function (this repo) to perform token validation -Update `keys.json` with the JSON Web Key Set (JWKS) format for your issuer. You can usually obtain the JWKS for your issuer by fetching the `jwks_uri` published in your issuer's metadata such as `${issuer}/.well-known/openid-configuration`. +(You will also need an IAM role for the Lambda function.) -> The authorizer only supports RSA signature keys +## Lambda authorizer quick setup - overview -> Ensure that your issuer uses a pinned key for token signatures and does not automatically rotate signing keys. The authorizer currently does not support persistence of cached keys (e.g. dynamo) obtained via metadata discovery. +The easiest way to get up and running with this authorizer is to create a new Lambda function using the publicly available S3 bucket as your code source, and add your environment variables to the function via the Lambda UI. -### Scopes +This default authorizer enforces scope-based access to API resources using the `scp` claim in the JWT. The `api:read` scope is required for `GET` requests and `api:write` scope for `POST`, `PUT`, `PATCH`, or `DELETE` requests. -This sample currently enforces scope-based access to API resources using the `scp` claim in the JWT. The `api:read` scope is required for `GET` requests and `api:write` scope for `POST`, `PUT`, `PATCH`, or `DELETE` requests. +To customize the authorizer, please see "Customizing the authorizer" below. -Update `index.js` with your authorization requirements and return the resulting AWS IAM Policy for the request. +## Lambda authorizer quick setup - steps -# Deployment +From the [AWS Lambda console](https://console.aws.amazon.com/lambda/home#/create?step=2) -### Install Dependencies +* Author from scratch -Run `npm install` to download all of the authorizer's dependent modules. This is a prerequisite for deployment as AWS Lambda requires these files to be included in the uploaded bundle. +* **Function name:** oauth2-jwt-authorizer +* **Runtime:** Node.js 8.10 +* **Permissions**: choose or create a role with basic Lambda permissions (if you need help creating a role see below) -### Create Bundle +click Create function -You can create the bundle using `npm run zip`. This creates a oauth2-jwt-authorizer.zip deployment package in the `dist` folder with all the source, configuration and node modules AWS Lambda needs. +On the main function screen: -### Create Lambda function +* **Code entry type:** Upload a file from Amazon S3 +* **Amazon S3 link URL:** https://s3.us-east-2.amazonaws.com/tom-smith-okta/aws-lambda-authorizer/lambda-oauth2-jwt-authorizer.zip +* **Handler:** index.handler -From the [AWS Lambda console](https://console.aws.amazon.com/lambda/home#/create?step=2) +### Environment variables -* **Name:** oauth2-jwt-authorizer -* **Description:** OAuth2 Bearer JWT authorizer for API Gateway -* **Runtime:** Node.js 4.3 -* **Code entry type:** Upload a .ZIP file -* **Upload:** *select `dist\lambda-oauth2-jwt-authorizer.zip` we created in the previous step* -* **Handler:** index.handler -* **Role:** *select an existing role with `lambda:InvokeFunction` action* +Enter the following environment variables on the main function screen: - > If you don't have an existing role, you will need to create a new role as outlined below +ISSUER -> from your Okta authorization server -* **Memory (MB):** 128 -* **Timeout:** 30 seconds -* **VPC:** No VPC +AUDIENCE -> from your Okta authorization server -Click **Next** and **Create** +CLIENT_ID -> a client_id from your Okta tenant -### Create IAM Role +The `audience` value should uniquely identify your AWS API Gateway deployment. You should assign unique audiences for each API Gateway authorizer instance so that a token intended for one gateway is not valid for another. -You will need to create an IAM Role that has permissions to invoke the Lambda function we created above. +Click **Save** to create your function. + +### Creating an IAM Role for the Lambda function + +If you don't already have an IAM role with permissions to create a Lambda function, you will need to create an IAM Role. That Role will need to have a Policy similar to the following: @@ -85,23 +83,23 @@ That Role will need to have a Policy similar to the following: } ``` -### Configure API Gateway +## Add the Lambda Authorizer to API Gateway From the [AWS API Gateway console](https://console.aws.amazon.com/apigateway/home) Open your API, or Create a new one. -In the left panel, under your API name, click on **Custom Authorizers**. Click on **Create** +In the left panel, under your API name, click on **Authorizers**. Click on **Create New Authorizer** * **Name:** oauth2-jwt-authorizer -* **Lambda region:** *from previous step* -* **Execution role:** *the ARN of the Role we created in the previous step* -* **Identity token source:** `method.request.header.Authorization` +* **Type:** Lambda +* **Lambda function:** choose your new Lambda function +* **Lambda Invoke Role:** the ARN of the Role we created in the previous step +* **Lambda Event Payload:** Token +* **Token source:** Authorization * **Token validation expression:** `^Bearer [-0-9a-zA-z\.]*$` - > Cut-and-paste this regular expression from ^ to $ inclusive - -* **Result TTL in seconds:** 300 + > Copy-and-paste this regular expression from ^ to $ inclusive Click **Create** @@ -132,14 +130,16 @@ A successful test will look something like: ] } -### Configure API Gateway Methods to use the Authorizer +## Configure API Gateway Methods to use the Authorizer In the left panel, under your API name, click on **Resources**. Under the Resource tree, select one of your Methods (POST, GET etc.) -Select **Method Request**. Under **Authorization Settings** change: +Select **Method Request**. Under **Settings** change: + +* Authorization: oauth2-jwt-authorizer -* Authorizer : oauth2-jwt-authorizer +Note: if you don't see your new Lambda function in the drop-down, refresh the page. Make sure that: @@ -151,7 +151,7 @@ Click the tick to save the changes. You need to Deploy the API to make the changes public. -Select **Action** and **Deploy API**. Select your **Stage**. +Select **Actions** and **Deploy API**. Select your **Stage**. ### Test your endpoint remotely @@ -173,3 +173,23 @@ You can use Postman to test the REST API #### In (modern) browsers console with fetch fetch( '', { method: 'POST', headers: { Authorization : 'Bearer ' }}).then(response => { console.log( response );}); + +# Customizing the Authorizer + +This sample currently enforces scope-based access to API resources using the `scp` claim in the JWT. The `api:read` scope is required for `GET` requests and `api:write` scope for `POST`, `PUT`, `PATCH`, or `DELETE` requests. + +To add your own scopes and policies, update `index.js` with your authorization requirements. + +## Manual Deployment + +### Install Dependencies + +Run `npm install` to download all of the authorizer's dependent modules. This is a prerequisite for deployment as AWS Lambda requires these files to be included in the uploaded bundle. + +### Create Bundle + +You can create the bundle using `npm run zip`. This creates a oauth2-jwt-authorizer.zip deployment package in the `dist` folder with all the source, configuration and node modules AWS Lambda needs. + +### Create Lambda function + +Follow the instructions above to create the Lambda function. You can either upload the new .zip file directly or upload it to an S3 bucket. diff --git a/index.js b/index.js index fb85396..649c51c 100644 --- a/index.js +++ b/index.js @@ -1,24 +1,36 @@ +/******************************************************/ +// Okta lambda authorizer for Amazon API Gateway + require('dotenv').config(); -const JwtTokenHandler = require('oauth2-bearer-jwt-handler').JwtTokenHandler; -const AuthPolicy = require('./auth-policy'); -const fs = require('fs'); -const jwtTokenHandler = new JwtTokenHandler({ - issuer: process.env.ISSUER, - audience: process.env.AUDIENCE, - jwks: fs.readFileSync('keys.json', 'utf8'), +const OktaJwtVerifier = require('@okta/jwt-verifier'); + +/******************************************************/ + +const oktaJwtVerifier = new OktaJwtVerifier({ + issuer: process.env.ISSUER, // required + clientId: process.env.CLIENT_ID, // required + assertClaims: { + aud: process.env.AUDIENCE + } }); +const AuthPolicy = require('./auth-policy'); + +/******************************************************/ + exports.handler = function(event, context) { - jwtTokenHandler.verifyRequest({ - headers: { - authorization: event.authorizationToken - } - }, function(err, claims) { - if (err) { - console.log('Failed to validate bearer token', err); - return context.fail('Unauthorized'); - } + + var arr = event.authorizationToken.split(" "); + + var access_token = arr[1]; + + oktaJwtVerifier.verifyAccessToken(access_token) + .then(jwt => { + // the token is valid (per definition of 'valid' above) + console.log(jwt.claims); + + var claims = jwt.claims; console.log('request principal: ' + claims); @@ -30,32 +42,39 @@ exports.handler = function(event, context) { apiOptions.restApiId = apiGatewayArnPart[0]; apiOptions.stage = apiGatewayArnPart[1]; const method = apiGatewayArnPart[2]; - const resource = '/'; // root resource + var resource = '/'; // root resource if (apiGatewayArnPart[3]) { resource += apiGatewayArnPart[3]; } - const policy = new AuthPolicy(claims.sub, awsAccountId, apiOptions); - + var policy = new AuthPolicy(claims.sub, awsAccountId, apiOptions); /* example scope based authorization to allow full access: policy.allowAllMethods() - */ - if (claims.hasScopes('api:read')) { + */ + + if (claims.scp.includes('api:read')) { policy.allowMethod(AuthPolicy.HttpVerb.GET, "*"); - } else if (claims.hasScopes('api:write')) { + } + else if (claims.scp.includes('api:write')) { policy.allowMethod(AuthPolicy.HttpVerb.POST, "*"); policy.allowMethod(AuthPolicy.HttpVerb.PUT, "*"); policy.allowMethod(AuthPolicy.HttpVerb.PATCH, "*"); policy.allowMethod(AuthPolicy.HttpVerb.DELETE, "*"); } + policy.allowMethod(AuthPolicy.HttpVerb.HEAD, "*"); policy.allowMethod(AuthPolicy.HttpVerb.OPTIONS, "*"); return context.succeed(policy.build()); + }) + .catch(err => { + + console.log(err) + return context.fail('Unauthorized'); }); } diff --git a/keys.json b/keys.json deleted file mode 100644 index bc4dc0c..0000000 --- a/keys.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "keys": [{ - "alg": "RS256", - "e": "AQAB", - "n": "kpsScWCpInSISpXuYjUfBo_xpvCWlCf89ft4gkiHoCtkvl_YJE6jZsR2i9vKDXCC4uEr5DXVnt7KRcnO74OM3ne5ZttOS5SPvmBInEFt95OPulavqeK4gVEzd7WEVDvzTe0PrJRPW62QKMObBxASbsUcPm5b8o8Yogg0izR4Y87QcR-rxOiBPhEuIVHwHVmdB_1HxSfVECDuE8DY2CL8n9tZj_HQTgV6gBA6c0i7CotEFgZCaOvO4eHyqqq9wBWaD0wbHAqRKWOYEQiGpTmSWa7yNta1aWa4rxIdidVIYENaMu7R_EbFLP7S5dBAVXdtHhMfscb9ri7rnDSeRfPF_Q", - "kid": "-XKPkvgICO6TOT8TnYDnfbVvxV6K1o_dyFMi2cnaI9w", - "kty": "RSA", - "use": "sig" - }] -} diff --git a/package.json b/package.json index 77b938f..ec49a13 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,7 @@ }, "homepage": "https://github.com/mcguinness/node-lambda-oauth2-jwt-authorizer.git#readme", "dependencies": { - "dotenv": "^2.0.0", - "oauth2-bearer-jwt-handler": "https://github.com/mcguinness/node-oauth2-bearer-jwt-handler.git" - }, - "devDependencies": { - "lambda-local": "^1.1.0", - "grunt": "^1.0.1", - "grunt-aws": "^0.6.1", - "grunt-aws-lambda": "^0.13.0", - "grunt-env": "^0.4.4", - "load-grunt-tasks": "^3.4.0" + "@okta/jwt-verifier": "0.0.14", + "dotenv": "^7.0.0" } }