Skip to content
This repository was archived by the owner on Feb 11, 2023. It is now read-only.

Externalise configuration and secrets #70

Open
wants to merge 75 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
c7be318
Merge pull request #2 from iress/fix-code-challenge-import
apgrucza May 28, 2020
ff883df
Generic package (#3)
apgrucza May 29, 2020
dd7fd03
Fixed release asset name (#4)
apgrucza May 29, 2020
a228ede
Release asset name (#5)
apgrucza May 31, 2020
655da6c
Convert tabs to spaces (#6)
apgrucza Jun 5, 2020
fb89b40
Add reference to GitHub Releases (#7)
apgrucza Jun 5, 2020
36bec4c
Mocha (#8)
apgrucza Jun 16, 2020
748a756
Pass expiresIn as seconds rather than milliseconds (#9)
apgrucza Jul 16, 2020
280de0b
CI scripts to exclude devDependencies from Lambda (#10)
apgrucza Jul 17, 2020
c1bade0
Exclude devDependencies when using build.sh (#11)
apgrucza Jul 31, 2020
1d83d49
Use access token and dont re-sign using custom keys. (#13)
nbshetty Oct 5, 2020
c0941d5
Fix/nonce check (#16)
nbshetty Oct 7, 2020
5eba0d3
use the access token kid to pick up the signing key (#17)
nbshetty Oct 8, 2020
ac736eb
PFS-1811 Add scope variable for AUTH_REQUEST
Iress-Kian Apr 29, 2021
30d2dd8
Merge pull request #18 from iress/PFS-1811
Iress-Kian May 3, 2021
4785c61
PFS-1811 Update READ.ME (#19)
Iress-Kian May 3, 2021
30ef2cc
Change example base URL for Okta Native [skip ci] (#20)
apgrucza Jul 26, 2021
18b00ac
Bump Node version to 14 and update packages (#21)
apgrucza Jul 30, 2021
912a036
WTL-852 Add Terraform configuration (#22)
apgrucza Sep 8, 2021
16abdc5
Added Terraform example - CloudFront distribution (#23)
apgrucza Sep 12, 2021
9bd02c2
allow secrets manager secrets to be encrypted with a CMK
Sep 29, 2021
70bbe91
updated variable description
Sep 29, 2021
57af25c
Merge pull request #25 from iress/encrypt-sec-manager-secret-cmk
chris-wilbur-wilson Sep 30, 2021
863ec52
Allow user to specify a customized IDP. (#26)
Nov 30, 2021
2b9e826
Update fault value of IDP to be a space to avoid SSM parameter creati…
Dec 1, 2021
89df7c3
remove max version constraint on provider
Tar-Elendil Oct 12, 2022
3946160
secret rotation now depends on the permissions
iress-ac Oct 14, 2022
5c9c396
Ensure permissions are deployed before the secret rotation is applied
patrickherrera Oct 17, 2022
699dcf8
Merge pull request #29 from Tar-Elendil/provider_constraint_update
patrickherrera Oct 17, 2022
0fb3237
fix: Migrate away from 'override_json' which is deprecated in AWS Pro…
patrickherrera Oct 17, 2022
c6c30b5
implement logout
iress-ac Nov 9, 2022
51ee344
add logout path to generic config
iress-ac Nov 10, 2022
21b2496
add logout path variable
iress-ac Nov 10, 2022
cd16605
change logout to redirect to auth provider
iress-ac Nov 11, 2022
9ca91fb
fix redirect path
iress-ac Nov 11, 2022
505eebe
Merge pull request #36 from iress-ac/add-logout-route
iress-ac Nov 14, 2022
b1af407
Correct default logout path
iress-ac Nov 15, 2022
095c614
Merge pull request #37 from iress/default-logout-path
iress-ac Nov 15, 2022
f4c0e44
Merge pull request #32 from iress/deprecation_fix
anevis Jul 12, 2023
8455c15
fix: uri encode template replacement to ovoid XSS
SiCoe Sep 22, 2023
d9e32aa
NONCE and CV cookies as `secure` for pkce
SiCoe Sep 22, 2023
6fc36a2
all cookies sameSite as Strict for pkce
SiCoe Sep 25, 2023
c5fc6c4
HTML instead of URI encoding for unauthorized body
SiCoe Sep 25, 2023
801f50d
include nodejs18.x in build targets
SiCoe Sep 25, 2023
7d0c091
reference version 4 in docs and examples
SiCoe Sep 25, 2023
35b12be
Merge pull request #41 from iress/node-18
SiCoe Sep 26, 2023
bf49023
Merge pull request #39 from iress/DP-481
SiCoe Sep 26, 2023
d1a2225
Merge branch 'v4' into DP-479
SiCoe Sep 26, 2023
fee9836
Merge pull request #40 from iress/DP-479
SiCoe Sep 26, 2023
ac267da
use sameSite: strict and secure for openid
SiCoe Sep 26, 2023
360ada9
use sameSite: strict and secure for github
SiCoe Sep 26, 2023
c84be23
Merge pull request #42 from iress/openid-secure-samesite
SiCoe Sep 26, 2023
888a212
include node 14 in "engines" of package
SiCoe Sep 26, 2023
abc09fa
correct footers to correct repository url
SiCoe Sep 27, 2023
d34734d
include Content-Type header in responses
SiCoe Sep 27, 2023
60915a5
Merge pull request #44 from iress/content-type
SiCoe Sep 27, 2023
d46dbbe
Merge branch 'v4' into footer
SiCoe Sep 27, 2023
0d6d923
Merge pull request #43 from iress/footer
SiCoe Sep 27, 2023
185bfdb
replace dependancy 'entities' with 'html-entities'
SiCoe Oct 2, 2023
1d9f87a
Merge pull request #38 from iress/DP-803
SiCoe Oct 2, 2023
4454871
update npm dependancies to remove vulnerabilities
SiCoe Oct 2, 2023
183bf6b
Merge pull request #45 from iress/v4
SiCoe Oct 2, 2023
483f7e1
default runtime to nodejs16.x
SiCoe Oct 2, 2023
59cdf41
Merge pull request #46 from iress/fix-aws-sdk
SiCoe Oct 2, 2023
5824dc9
set SameSite cookie attribute to lax for CV and NONCE
SiCoe Oct 3, 2023
f051484
Merge pull request #47 from iress/SameSite-lax
SiCoe Oct 3, 2023
03efeef
fix: stop redirect loop caused by TOKEN cookie not sent
SiCoe Oct 3, 2023
bdd68f0
Merge pull request #48 from iress/fix-same-site
SiCoe Oct 3, 2023
c99c48d
update to nodejs20.x runtime (#50)
steve-forth Aug 29, 2024
fdcbd1f
uplift action versions to v4
steve-forth Mar 6, 2025
15d51f8
Merge pull request #51 from iress/actions/upload-artifact-v4
steve-forth Mar 10, 2025
2cfa8ec
update action-gh-release to v2
steve-forth Mar 11, 2025
9d1505a
Merge pull request #52 from iress/actions/gh-release-v2
steve-forth Mar 11, 2025
78fdd1a
add write contents permission to release action
steve-forth Mar 12, 2025
ab8b10a
Merge pull request #54 from iress/actions/fix-release-action
steve-forth Mar 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Generic Package CI
on: push

jobs:
build:

runs-on: ubuntu-latest

strategy:
matrix:
node-version: [20.x]

steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- run: npm run-script test-ci

- run: npm run-script build-ci okta_native
- run: test -f distributions/okta_native/okta_native.zip
- run: npm run-script build-ci rotate_key_pair
- run: test -f distributions/rotate_key_pair/rotate_key_pair.zip

- uses: actions/upload-artifact@v4
with:
name: packages_${{ matrix.node-version }}
path: distributions/*/*.zip
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Upload Release Assets

on:
push:
# Match against refs/tags
tags:
- 'v*' # Push events matching v*, e.g. v1.0.0, v1.1.0

jobs:
build:
name: Upload Release Assets
runs-on: ubuntu-latest
permissions:
contents: write
steps:

- name: Checkout code
uses: actions/checkout@v4

- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Run tests
run: npm run-script test-ci

- name: Build okta_native
run: npm run-script build-ci okta_native

- name: Build rotate_key_pair
run: npm run-script build-ci rotate_key_pair

- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
./distributions/okta_native/okta_native.zip
./distributions/rotate_key_pair/rotate_key_pair.zip
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -13,3 +13,6 @@ tests/logs/*
tests/config
/distributions
packaged.yaml
.terraform*
terraform.tfstate*
/infra/terraform/modules/_lambda/packages/*
69 changes: 59 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
# Cloudfront Auth

[Google Apps (G Suite)](https://developers.google.com/identity/protocols/OpenIDConnect), [Microsoft Azure AD](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-protocols-oauth-code), [GitHub](https://developer.github.com/apps/building-oauth-apps/authorization-options-for-oauth-apps/), [OKTA](https://www.okta.com/), [Auth0](https://auth0.com/), [Centrify](https://centrify.com) authentication for [CloudFront](https://aws.amazon.com/cloudfront/) using [Lambda@Edge](http://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html). The original use case for `cloudfront-auth` was to serve private S3 content over HTTPS without running a proxy server in EC2 to authenticate requests; but `cloudfront-auth` can be used authenticate requests of any Cloudfront origin configuration.

## Description

Upon successful authentication, a cookie (named `TOKEN`) with the value of a signed JWT is set and the user redirected back to the originally requested path. Upon each request, Lambda@Edge checks the JWT for validity (signature, expiration date, audience and matching hosted domain) and will redirect the user to configured provider's login when their session has timed out.

## Usage

If your CloudFront distribution is pointed at a S3 bucket, [configure origin access identity](http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html#private-content-creating-oai-console) so S3 objects can be stored with private permissions. (Origin access identity requires the S3 ACL owner be the account owner. Use our [s3-object-owner-monitor](https://github.com/Widen/s3-object-owner-monitor) Lambda function if writing objects across multiple accounts.)

Enable SSL/HTTPS on your CloudFront distribution; AWS Certificate Manager can be used to provision a no-cost certificate.

Session duration is defined as the number of hours that the JWT is valid for. After session expiration, cloudfront-auth will redirect the user to the configured provider to re-authenticate. RSA keys are used to sign and validate the JWT. If the files `id_rsa` and `id_rsa.pub` do not exist they will be automatically generated by the build. To disable all issued JWTs upload a new ZIP using the Lambda Console after deleting the `id_rsa` and `id_rsa.pub` files (a new key will be automatically generated).
Session duration is defined as the number of hours that the JWT is valid for. After session expiration, cloudfront-auth will redirect the user to the configured provider to re-authenticate. RSA keys are used to sign and validate the JWT.

## Lambda Deployment Packages

### Custom Packages

A custom package has all the configuration baked into it (including secrets). To build a custom package, execute:

```bash
./build.sh
```

The build script prompts you for the required configuration parameters, which are described in [Identity Provider Guides](#identity-provider-guides). If the files `id_rsa` and `id_rsa.pub` do not exist they will be automatically generated by the build. To disable all issued JWTs upload a new ZIP using the Lambda Console after deleting the `id_rsa` and `id_rsa.pub` files (a new key will be automatically generated).

### Generic Packages

A generic package retrieves its configuration at runtime from the [AWS Systems Manager](https://aws.amazon.com/systems-manager/) Parameter Store and [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/). Currently, support for this type of package has been added for OKTA Native only. To disable all issued JWTs, rotate the key pair using the Secrets Manager console.

Generic packages are pre-built and available for download from the Releases page of this GitHub repository. [Terraform modules](./infra/terraform/README.md) are available for downloading and deploying generic packages and their configuration resources.

## Identity Provider Guides

@@ -20,7 +42,7 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. For **Authorization callback URL** enter your Cloudfront hostname with your preferred path value for the authorization callback. Example: `https://my-cloudfront-site.example.com/_callback`
1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated.
1. Choose `Github` as the authorization method and enter the values for Client ID, Client Secret, Redirect URI, Session Duration and Organization
- cloudfront-auth will check that users are a member of the entered Organization.
* cloudfront-auth will check that users are a member of the entered Organization.
1. Upload the resulting `zip` file found in your distribution folder using the AWS Lambda console and jump to the [configuration step](#configure-lambda-and-cloudfront)

### Google
@@ -47,7 +69,7 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. In your Azure portal, go to Azure Active Directory and select **App registrations**
1. Create a new application registration with an application type of **Web app / api**
1. Once created, go to your application `Settings -> Keys` and make a new key with your desired duration. Click save and copy the value. This will be your `client_secret`
1. Above where you selected `Keys`, go to `Reply URLs` and enter your Cloudfront hostname with your preferred path value for the authorization callback. Example: https://my-cloudfront-site.example.com/_callback
1. Above where you selected `Keys`, go to `Reply URLs` and enter your Cloudfront hostname with your preferred path value for the authorization callback. Example: `https://my-cloudfront-site.example.com/_callback`
1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated.
1. Choose `Microsoft` as the authorization method and enter the values for [Tenant](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-howto-tenant), Client ID (**Application ID**), Client Secret (**previously created key**), Redirect URI and Session Duration
1. Select the preferred authentication method
@@ -115,32 +137,59 @@ Session duration is defined as the number of hours that the JWT is valid for. Af
1. Client Id from the application created in our previous step (can be found at the bottom of the general tab)
1. Base Url
1. This is named the 'Org URL' and can be found in the top right of the Dashboard tab.
1. To use the [generic package](#generic-packages), jump straight to [Terraform Modules for CloudFront Authentication](./infra/terraform/README.md).
1. Execute `./build.sh` in the downloaded directory. NPM will run to download dependencies and a RSA key will be generated.
1. Choose `OKTA Native` as the authorization method and enter the values for Base URL (Org URL), Client ID, PKCE Code Verifier Length, Redirect URI, and Session Duration
1. Upload the resulting `zip` file found in your distribution folder using the AWS Lambda console and jump to the [configuration step](#configure-lambda-and-cloudfront)

## Configure Lambda and CloudFront

[Manual Deployment](https://github.com/Widen/cloudfront-auth/wiki/Manual-Deployment) __*or*__ [AWS SAM Deployment](https://github.com/Widen/cloudfront-auth/wiki/AWS-SAM-Deployment)
See [Manual Deployment](https://github.com/Widen/cloudfront-auth/wiki/Manual-Deployment) __*or*__ [AWS SAM Deployment](https://github.com/Widen/cloudfront-auth/wiki/AWS-SAM-Deployment)

## Authorization Method Examples

- [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup)
* [Use Google Groups to authorize users](https://github.com/Widen/cloudfront-auth/wiki/Google-Groups-Setup)

- JSON array of email addresses
* JSON array of email addresses

```
```json
[ "foo@gmail.com", "bar@gmail.com" ]
```

## Testing

Detailed instructions on testing your function can be found [in the Wiki](https://github.com/Widen/cloudfront-auth/wiki/Debug-&-Test).

## Build Requirements
- [npm](https://www.npmjs.com/) ^5.6.0
- [node](https://nodejs.org/en/) ^10.0
- [openssl](https://www.openssl.org)

* [npm](https://www.npmjs.com/) ^7.20.0
* [node](https://nodejs.org/en/) ^20.0
* [openssl](https://www.openssl.org)

## Building Generic Packages

If you need to build a generic package yourself, execute:

```bash
./build.sh package
```

The supported values of `package` are:

* `okta_native` - builds a generic Lambda package for OKTA Native authentication
* `rotate_key_pair` - builds a Lambda package for rotating the RSA keys in AWS Secrets Manager

GitHub Actions automatically creates a new GitHub release when the repository owner pushes a tag that begins with `v`.

e.g.

```sh
git tag -a -s -m "Target AWS Lambda Node.js 14.x runtime" v3.0.0
git push origin v3.0.0
```

## Contributing

All contributions are welcome. Please create an issue in order open up communication with the community.

When implementing a new flow or using an already implemented flow, be sure to follow the same style used in `build.js`. The config.json file should have an object for each request made. For example, `openid.index.js` converts config.AUTH_REQUEST and config.TOKEN_REQUEST to querystrings for simplified requests (after adding dynamic variables such as state or nonce). For implementations that are not generic (most), endpoints are hardcoded in to the config (or discovery documents).
63 changes: 49 additions & 14 deletions authn/github.index.js
Original file line number Diff line number Diff line change
@@ -64,22 +64,33 @@ function mainProcess(event, context, callback) {
"statusDescription": "Found",
"body": "ID token retrieved.",
"headers": {
"content-type": [{
"key": "Content-Type",
"value": "text/plain;charset=UTF-8"
}],
"location" : [{
"key": "Location",
"value": event.Records[0].cf.config.hasOwnProperty('test') ? (config.AUTH_REQUEST.redirect_uri + queryDict.state) : queryDict.state
}],
"set-cookie" : [{
"key": "Set-Cookie",
"value" : cookie.serialize('TOKEN', jwt.sign(
{ },
config.PRIVATE_KEY.trim(),
"value" : cookie.serialize(
'TOKEN',
jwt.sign(
{ },
config.PRIVATE_KEY.trim(),
{
audience: headers.host[0].value,
subject: auth.getSubject(username),
expiresIn: config.SESSION_DURATION,
algorithm: 'RS256'
} // Options
),
{
audience: headers.host[0].value,
subject: auth.getSubject(username),
expiresIn: config.SESSION_DURATION,
algorithm: 'RS256'
} // Options
))
sameSite: 'lax',
secure: true
}
)
}],
},
};
@@ -140,13 +151,22 @@ function redirect(request, headers, callback) {
statusDescription: "Found",
body: "Redirecting to OAuth2 provider",
headers: {
"content-type": [{
"key": "Content-Type",
"value": "text/plain;charset=UTF-8"
}],
"location" : [{
"key": "Location",
"value": config.AUTHORIZATION_ENDPOINT + '?' + querystring
}],
"set-cookie" : [{
"key": "Set-Cookie",
"value" : cookie.serialize('TOKEN', '', { path: '/', expires: new Date(1970, 1, 1, 0, 0, 0, 0) })
"value" : cookie.serialize('TOKEN', '', {
path: '/',
expires: new Date(1970, 1, 1, 0, 0, 0, 0),
sameSite: 'lax',
secure: true
})
}],
},
};
@@ -159,10 +179,19 @@ function unauthorized(body, callback) {
"statusDescription": "Unauthorized",
"body": body,
"headers": {
"set-cookie" : [{
"key": "Set-Cookie",
"value" : cookie.serialize('TOKEN', '', { path: '/', expires: new Date(1970, 1, 1, 0, 0, 0, 0) })
}],
"content-type": [{
"key": "Content-Type",
"value": "text/plain;charset=UTF-8"
}],
"set-cookie" : [{
"key": "Set-Cookie",
"value" : cookie.serialize('TOKEN', '', {
path: '/',
expires: new Date(1970, 1, 1, 0, 0, 0, 0),
sameSite: 'lax',
secure: true
})
}],
},
};
callback(null, response);
@@ -173,6 +202,12 @@ function internalServerError(body, callback) {
"status": "500",
"statusDescription": "Internal Server Error",
"body": body,
"headers": {
"content-type": [{
"key": "Content-Type",
"value": "text/plain;charset=UTF-8"
}]
}
};
callback(null, response);
}
157 changes: 118 additions & 39 deletions authn/openid.index.js

Large diffs are not rendered by default.

274 changes: 180 additions & 94 deletions authn/pkce.index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build.sh
Original file line number Diff line number Diff line change
@@ -7,4 +7,4 @@ if [ ! -d "distributions" ]; then
fi
fi

npm run-script build
npm run-script build-ci "$@"
234 changes: 151 additions & 83 deletions build/build.js
Original file line number Diff line number Diff line change
@@ -9,83 +9,105 @@ const R = require('ramda');
var config = { AUTH_REQUEST: {}, TOKEN_REQUEST: {} };
var oldConfig;

prompt.message = colors.blue(">");
prompt.start();
prompt.get({
properties: {
distribution: {
message: colors.red("Enter distribution name"),
required: true
},
method: {
description: colors.red("Authentication methods:\n (1) Google\n (2) Microsoft\n (3) GitHub\n (4) OKTA\n (5) Auth0\n (6) Centrify\n (7) OKTA Native\n\n Select an authentication method")
}
}
}, function (err, result) {
config.DISTRIBUTION = result.distribution;
config.DISTRIBUTION = process.argv[2]

if (config.DISTRIBUTION) {
config.AUTHN = config.DISTRIBUTION.toUpperCase();
shell.mkdir('-p', 'distributions/' + config.DISTRIBUTION);
if (fs.existsSync('distributions/' + config.DISTRIBUTION + '/config.json')) {
oldConfig = JSON.parse(fs.readFileSync('./distributions/' + config.DISTRIBUTION + '/config.json', 'utf8'));
}
if (!fs.existsSync('distributions/' + config.DISTRIBUTION + '/id_rsa') || !fs.existsSync('./distributions/' + config.DISTRIBUTION + '/id_rsa.pub')) {
shell.exec("ssh-keygen -t rsa -m PEM -b 4096 -f ./distributions/" + config.DISTRIBUTION + "/id_rsa -N ''");
shell.exec("openssl rsa -in ./distributions/" + config.DISTRIBUTION + "/id_rsa -pubout -outform PEM -out ./distributions/" + config.DISTRIBUTION + "/id_rsa.pub");
}
switch (result.method) {
case '1':
if (R.pathOr('', ['AUTHN'], oldConfig) != "GOOGLE") {
oldConfig = undefined;
}
config.AUTHN = "GOOGLE";
googleConfiguration();
break;
case '2':
if (R.pathOr('', ['AUTHN'], oldConfig) != "MICROSOFT") {
oldConfig = undefined;
}
config.AUTHN = "MICROSOFT";
microsoftConfiguration();
switch (config.DISTRIBUTION) {
case 'okta_native':
genericOktaConfiguration();
break;
case '3':
if (R.pathOr('', ['AUTHN'], oldConfig) != "GITHUB") {
oldConfig = undefined;
}
config.AUTHN = "GITHUB";
githubConfiguration();
break;
case '4':
if (R.pathOr('', ['AUTHN'], oldConfig) != "OKTA") {
oldConfig = undefined;
}
config.AUTHN = "OKTA";
oktaConfiguration();
break;
case '5':
if (R.pathOr('', ['AUTHN'], oldConfig) != "AUTH0") {
oldConfig = undefined;
}
config.AUTHN = "AUTH0";
auth0Configuration();
break;
case '6':
if (R.pathOr('', ['AUTHN'], oldConfig) != "CENTRIFY") {
oldConfig = undefined;
}
config.AUTHN = "CENTRIFY";
centrifyConfiguration();
break;
case '7':
if (R.pathOr('', ['AUTHN'], oldConfig) != "OKTA_NATIVE") {
oldConfig = undefined;
}
config.AUTHN = "OKTA_NATIVE";
oktaConfiguration();
case 'rotate_key_pair':
buildRotateKeyPair();
break;
default:
console.log("Method not recognized. Stopping build...");
console.log(`Unsupported package name "${config.DISTRIBUTION}". Stopping build...`);
process.exit(1);
}
});
} else {
customConfiguration();
}

function customConfiguration() {
prompt.message = colors.blue(">");
prompt.start();
prompt.get({
properties: {
distribution: {
message: colors.red("Enter distribution name"),
required: true
},
method: {
description: colors.red("Authentication methods:\n (1) Google\n (2) Microsoft\n (3) GitHub\n (4) OKTA\n (5) Auth0\n (6) Centrify\n (7) OKTA Native\n\n Select an authentication method")
}
}
}, function (err, result) {
config.DISTRIBUTION = result.distribution;
shell.mkdir('-p', 'distributions/' + config.DISTRIBUTION);
if (fs.existsSync('distributions/' + config.DISTRIBUTION + '/config.json')) {
oldConfig = JSON.parse(fs.readFileSync('./distributions/' + config.DISTRIBUTION + '/config.json', 'utf8'));
}
if (!fs.existsSync('distributions/' + config.DISTRIBUTION + '/id_rsa') || !fs.existsSync('./distributions/' + config.DISTRIBUTION + '/id_rsa.pub')) {
shell.exec("ssh-keygen -t rsa -m PEM -b 4096 -f ./distributions/" + config.DISTRIBUTION + "/id_rsa -N ''");
shell.exec("openssl rsa -in ./distributions/" + config.DISTRIBUTION + "/id_rsa -pubout -outform PEM -out ./distributions/" + config.DISTRIBUTION + "/id_rsa.pub");
}
switch (result.method) {
case '1':
if (R.pathOr('', ['AUTHN'], oldConfig) != "GOOGLE") {
oldConfig = undefined;
}
config.AUTHN = "GOOGLE";
googleConfiguration();
break;
case '2':
if (R.pathOr('', ['AUTHN'], oldConfig) != "MICROSOFT") {
oldConfig = undefined;
}
config.AUTHN = "MICROSOFT";
microsoftConfiguration();
break;
case '3':
if (R.pathOr('', ['AUTHN'], oldConfig) != "GITHUB") {
oldConfig = undefined;
}
config.AUTHN = "GITHUB";
githubConfiguration();
break;
case '4':
if (R.pathOr('', ['AUTHN'], oldConfig) != "OKTA") {
oldConfig = undefined;
}
config.AUTHN = "OKTA";
oktaConfiguration();
break;
case '5':
if (R.pathOr('', ['AUTHN'], oldConfig) != "AUTH0") {
oldConfig = undefined;
}
config.AUTHN = "AUTH0";
auth0Configuration();
break;
case '6':
if (R.pathOr('', ['AUTHN'], oldConfig) != "CENTRIFY") {
oldConfig = undefined;
}
config.AUTHN = "CENTRIFY";
centrifyConfiguration();
break;
case '7':
if (R.pathOr('', ['AUTHN'], oldConfig) != "OKTA_NATIVE") {
oldConfig = undefined;
}
config.AUTHN = "OKTA_NATIVE";
oktaConfiguration();
break;
default:
console.log("Method not recognized. Stopping build...");
process.exit(1);
}
});
}

function microsoftConfiguration() {
prompt.message = colors.blue(">>");
@@ -368,25 +390,62 @@ function oktaConfiguration() {
config.TOKEN_REQUEST.client_id = result.CLIENT_ID;
config.TOKEN_REQUEST.redirect_uri = result.REDIRECT_URI;
config.TOKEN_REQUEST.grant_type = 'authorization_code';
var files = ['config.json', 'index.js', 'auth.js', 'nonce.js'];
if(result.CLIENT_SECRET) {
if (config.AUTHN == 'OKTA') {
config.TOKEN_REQUEST.client_secret = result.CLIENT_SECRET;
shell.cp('./authn/openid.index.js', './distributions/' + config.DISTRIBUTION + '/index.js');
} else {
} else if (config.AUTHN == 'OKTA_NATIVE') {
config.PKCE_CODE_VERIFIER_LENGTH = result.PKCE_CODE_VERIFIER_LENGTH || "96";
shell.cp('./code-challenge.js', './distributions/' + config.DISTRIBUTION + '/code-challenge.js');
shell.cp('./authn/pkce.index.js', './distributions/' + config.DISTRIBUTION + '/index.js');
files.push('code-challenge.js');
}
config.AUTHZ = "OKTA";

shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js');
fs.writeFileSync('distributions/' + config.DISTRIBUTION + '/config.json', JSON.stringify(result, null, 4));
shell.cp('./authz/okta.js', './distributions/' + config.DISTRIBUTION + '/auth.js');
writeConfig(config, zip, files);
buildOkta();
});
}

function genericOktaConfiguration() {
config.PRIVATE_KEY = '${private-key}';
config.PUBLIC_KEY = '${public-key}';

config.DISCOVERY_DOCUMENT = '${base-url}/.well-known/openid-configuration';
config.SESSION_DURATION = '${session-duration}';

config.BASE_URL = '${base-url}';
config.CALLBACK_PATH = '${callback-path}';
config.LOGOUT_PATH = '${logout-path}';

config.AUTH_REQUEST.client_id = '${client-id}';
config.AUTH_REQUEST.response_type = 'code';
config.AUTH_REQUEST.scope = '${scope}';
config.AUTH_REQUEST.redirect_uri = 'https://${domain-name}${callback-path}';
config.AUTH_REQUEST.idp = '${idp}';

config.TOKEN_REQUEST.client_id = '${client-id}';
config.TOKEN_REQUEST.redirect_uri = 'https://${domain-name}${callback-path}';
config.TOKEN_REQUEST.grant_type = 'authorization_code';
if (config.AUTHN == 'OKTA') {
config.TOKEN_REQUEST.client_secret = '${client-secret}';
} else if (config.AUTHN == 'OKTA_NATIVE') {
config.PKCE_CODE_VERIFIER_LENGTH = '${pkce-code-verifier-length}';
}

buildOkta(true);
}

function buildOkta(isGeneric) {
var files = ['config.json', 'config.js', 'index.js', 'auth.js', 'nonce.js'];
if (config.AUTHN == 'OKTA') {
shell.cp('./authn/openid.index.js', './distributions/' + config.DISTRIBUTION + '/index.js');
} else if (config.AUTHN == 'OKTA_NATIVE') {
shell.cp('./code-challenge.js', './distributions/' + config.DISTRIBUTION + '/code-challenge.js');
shell.cp('./authn/pkce.index.js', './distributions/' + config.DISTRIBUTION + '/index.js');
files.push('code-challenge.js');
}
config.AUTHZ = "OKTA";

shell.cp('./nonce.js', './distributions/' + config.DISTRIBUTION + '/nonce.js');
shell.cp('./authz/okta.js', './distributions/' + config.DISTRIBUTION + '/auth.js');
shell.cp(isGeneric ? './config/generic.config.js' : './config/custom.config.js', './distributions/' + config.DISTRIBUTION + '/config.js');
writeConfig(config, zip, files);
}

function githubConfiguration() {
prompt.message = colors.blue(">>");
prompt.start();
@@ -583,11 +642,20 @@ function centrifyConfiguration() {
});
}

function buildRotateKeyPair() {
shell.exec('zip -q -j distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip ./rotate-key-pair/index.js');
console.log(colors.green("Done... created Lambda function distributions/" + config.DISTRIBUTION + "/" + config.DISTRIBUTION + ".zip"));
}

function zip(files) {
var filesString = '';
for (var i = 0; i < files.length; i++) {
filesString += ' distributions/' + config.DISTRIBUTION + '/' + files[i] + ' ';
}
try {
fs.unlinkSync('distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip');
} catch (err) {
}
shell.exec('zip -q distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip ' + 'package-lock.json package.json -r node_modules');
shell.exec('zip -q -r -j distributions/' + config.DISTRIBUTION + '/' + config.DISTRIBUTION + '.zip ' + filesString);
console.log(colors.green("Done... created Lambda function distributions/" + config.DISTRIBUTION + "/" + config.DISTRIBUTION + ".zip"));
593 changes: 503 additions & 90 deletions build/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/package.json
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.19.0",
"axios": "^0.21.1",
"node-rsa": "^0.4.2",
"prompt": "^1.0.0",
"ramda": "^0.25.0",
13 changes: 13 additions & 0 deletions config/custom.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const fsPromises = require('fs').promises;

module.exports.getConfig = function (fileName, functionName, callback) {
// Read config file
fsPromises.readFile(fileName, 'utf8').then(function (configText) {
// Parse config file
const config = JSON.parse(configText);

callback(null, config);
}).catch(function (err) {
callback(err);
});
};
43 changes: 43 additions & 0 deletions config/generic.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const fsPromises = require('fs').promises;
const aws = require('aws-sdk');
const fillTemplate = require('es6-dynamic-template');

module.exports.getConfig = function (fileName, functionName, callback) {
// Remove the 'us-east-1.' prefix that exists on Lambda@Edge replicas
const name = functionName.replace(/^us-east-1./, '');

// Read config file
const readFilePromise = fsPromises.readFile(fileName, 'utf8');

// Get parameters from SSM Parameter Store
const ssm = new aws.SSM({ region: 'us-east-1' });
const getParametersByPathPromise = ssm.getParametersByPath({ Path: `/${name}` }).promise();

// Get key pair from Secrets Manager
const secretsmanager = new aws.SecretsManager({ region: 'us-east-1' });
const getSecretValuePromise = secretsmanager.getSecretValue({ SecretId: `${name}/key-pair` }).promise();

Promise.all([readFilePromise, getParametersByPathPromise, getSecretValuePromise]).then(function (values) {
const template = values[0];
const ssmParameters = values[1].Parameters;
const secretString = values[2].SecretString;

// Flatten parameters into name-value pairs
const parameters = ssmParameters.reduce(function (map, obj) {
map[obj.Name.slice(name.length + 2)] = obj.Value;
return map;
}, {});

// Convert secret to name-value pairs
const secrets = JSON.parse(secretString);

// Parse config file, replacing template placeholders with parameters and secrets
const config = JSON.parse(template, (key, value) =>
typeof value === 'string' ? fillTemplate(value, { ...parameters, ...secrets }) : value
);

callback(null, config);
}).catch(function (err) {
callback(err);
});
};
67 changes: 67 additions & 0 deletions infra/terraform/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Terraform Modules for CloudFront Authentication

This directory contains the _Terraform_ configuration for adding authentication to a CloudFront distribution. Currently only OKTA Native authentication is supported.

## Usage

The Terraform modules for each identity provider are in the [modules](./modules) directory. Refer to the [examples](./examples) directory for Terraform configuration that you can include in your project and adapt. Refer to the `variables.tf` file of the module to see all the available input variables. Below is an example for OKTA Native.

1. Call the module in your Terraform configuration. CloudFront uses the `us-east-1` region, so you must pass a `us-east-1` provider to the module.

```hcl
module "auth" {
source = "github.com/iress/cloudfront-auth//infra/terraform/modules/okta_native"
# Lambda function version to deploy (see the Releases page of this GitHub repository)
release_version = "v4.0.0"
name = "my-website-auth"
org_url = "https://my-org.okta.com/oauth2/default"
client_id = "Nf2qSD9wXKU9ph8an22T"
domain_name = "my-cloudfront-site.example.com"
# aws.global_services is a us-east-1 provider
providers = {
aws = aws.global_services
}
}
```
1. Add a [lambda_function_association](https://www.terraform.io/docs/providers/aws/r/cloudfront_distribution.html#lambda_function_association) to your [aws_cloudfront_distribution](https://www.terraform.io/docs/providers/aws/r/cloudfront_distribution.html) resource:
```hcl
resource "aws_cloudfront_distribution" "distribution" {
# ... other configuration ...
# lambda_function_association is also supported by ordered_cache_behavior
default_cache_behavior {
# ... other configuration ...
lambda_function_association {
event_type = "viewer-request"
lambda_arn = module.auth.auth_lambda_arn
}
}
}
```
## Requirements
This module requires [wget](https://www.gnu.org/software/wget/) to be installed on the machine or container that runs Terraform.
## Logs
Logs are written to CloudWatch. The table below shows where the logs can be found, where {name} is the value of the `name` input variable in the Terraform module.
| Function | Log group name | Region |
|----------|----------------|--------|
| Authentication | /aws/lambda/us-east-1.{name} | The region closest to the user who made the request to the website
| Secret rotation | /aws/lambda/{name}-rotation | us-east-1
## Destroying
The first time you run `terraform destroy` you may receive the following error:
*Lambda was unable to delete arn:aws:lambda:us-east-1:553479592532:function:my-website-auth:1 because it is a replicated function. Please see our [documentation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-delete-replicas.html) for Deleting Lambda@Edge Functions and Replicas.*
When this occurs, wait (up to a few hours) for CloudFront to delete the Lambda function replicas, then run `terraform destroy` again.

Choose a reason for hiding this comment

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

This should be prominently displayed as it is annoying to run into

53 changes: 53 additions & 0 deletions infra/terraform/examples/cloudfront-s3/cloudfront.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
locals {
s3_origin_id = var.name
}

resource "aws_cloudfront_origin_access_identity" "example" {
comment = var.name
}

resource "aws_cloudfront_distribution" "example" {
origin {
domain_name = aws_s3_bucket.example.bucket_regional_domain_name
origin_id = local.s3_origin_id

s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.example.cloudfront_access_identity_path
}
}

enabled = true
comment = var.name
default_root_object = "index.html"

default_cache_behavior {
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id

forwarded_values {
query_string = false

cookies {
forward = "none"
}
}

viewer_protocol_policy = "redirect-to-https"

lambda_function_association {
event_type = "viewer-request"
lambda_arn = var.lambda_arn
}
}

restrictions {
geo_restriction {
restriction_type = "none"
}
}

viewer_certificate {
cloudfront_default_certificate = true
}
}
9 changes: 9 additions & 0 deletions infra/terraform/examples/cloudfront-s3/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>Cloudfront authentication example</title>
</head>
<body>
<p>Cloudfront authentication is working correctly.</p>
</body>
</html>
4 changes: 4 additions & 0 deletions infra/terraform/examples/cloudfront-s3/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "domain_name" {
value = aws_cloudfront_distribution.example.domain_name
description = "The domain name corresponding to the CloudFront distribution"
}
29 changes: 29 additions & 0 deletions infra/terraform/examples/cloudfront-s3/s3.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
resource "aws_s3_bucket" "example" {
bucket_prefix = "${var.name}-"
acl = "private"
}

data "aws_iam_policy_document" "oai_access" {
statement {
sid = "Allow-OAI-Access-To-Bucket"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.example.arn}/*"]

principals {
type = "AWS"
identifiers = [aws_cloudfront_origin_access_identity.example.iam_arn]
}
}
}

resource "aws_s3_bucket_policy" "oai_access" {
bucket = aws_s3_bucket.example.id
policy = data.aws_iam_policy_document.oai_access.json
}

resource "aws_s3_bucket_object" "index" {
bucket = aws_s3_bucket.example.id
key = "index.html"
source = "${path.module}/index.html"
content_type = "text/html"
}
9 changes: 9 additions & 0 deletions infra/terraform/examples/cloudfront-s3/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
variable "name" {
description = "A name for the AWS resources created by this module"
type = string
}

variable "lambda_arn" {
description = "The Amazon Resource Name (ARN) identifying the Lambda Function Version to associate with the CloudFront distribution"
type = string
}
24 changes: 24 additions & 0 deletions infra/terraform/examples/okta-native/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
locals {
name = "cloudfront-auth-example-okta-native"
}

module "auth" {
source = "github.com/iress/cloudfront-auth//infra/terraform/modules/okta_native"

release_version = "v4.0.0"
name = local.name
org_url = "https://my-org.okta.com/oauth2/default"
client_id = "Nf2qSD9wXKU9ph8an22T"
domain_name = module.cloudfront_s3.domain_name

providers = {
aws = aws.global_services
}
}

module "cloudfront_s3" {
source = "../cloudfront-s3"

name = local.name
lambda_arn = module.auth.auth_lambda_arn
}
4 changes: 4 additions & 0 deletions infra/terraform/examples/okta-native/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "domain_name" {
value = module.cloudfront_s3.domain_name
description = "The domain name corresponding to the CloudFront distribution"
}
8 changes: 8 additions & 0 deletions infra/terraform/examples/okta-native/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
terraform {
required_version = ">= 1.0.0"
}

provider "aws" {
region = "us-east-1"
alias = "global_services"
}
52 changes: 52 additions & 0 deletions infra/terraform/modules/_auth/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
data "aws_caller_identity" "current" {}

data "aws_region" "current" {}

data "aws_iam_policy_document" "auth" {
statement {
actions = ["ssm:GetParametersByPath"]
resources = ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/${var.name}"]
}

statement {
actions = ["secretsmanager:GetSecretValue"]
resources = ["arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.name}/*"]
}
}

data "aws_iam_policy_document" "rotation" {
statement {
actions = ["secretsmanager:PutSecretValue"]
resources = ["arn:aws:secretsmanager:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:secret:${var.name}/*"]
}
}

module "auth" {
source = "../_lambda"

name = var.name
tags = var.tags
package_url = var.package_url
timeout = 5
iam_policy_override_json = data.aws_iam_policy_document.auth.json
lambda_at_edge = true
kms_key_arn = var.kms_key_arn
}

module "rotation" {
source = "../_lambda"

name = "${var.name}-rotation"
tags = var.tags
package_url = "https://github.com/iress/cloudfront-auth/releases/download/${var.release_version}/rotate_key_pair.zip"
timeout = 30
iam_policy_override_json = data.aws_iam_policy_document.rotation.json
kms_key_arn = var.kms_key_arn
}

resource "aws_lambda_permission" "allow_secrets_manager" {
statement_id = "AllowExecutionFromSecretsManager"
action = "lambda:InvokeFunction"
function_name = module.rotation.lambda_arn
principal = "secretsmanager.amazonaws.com"
}
4 changes: 4 additions & 0 deletions infra/terraform/modules/_auth/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "auth_lambda_arn" {
value = module.auth.lambda_arn
description = "The Amazon Resource Name (ARN) identifying the authentication Lambda Function Version"
}
10 changes: 10 additions & 0 deletions infra/terraform/modules/_auth/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Provider passed in should always be us-east-1

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 3.28"
}
}
}
20 changes: 20 additions & 0 deletions infra/terraform/modules/_auth/secrets.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
resource "aws_secretsmanager_secret" "key_pair" {
name = "${var.name}/key-pair"

Choose a reason for hiding this comment

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

if this name could have a random string added to the end, it would solve the issue of
'You can't perform this operation on the secret because it was marked for deletion.'

recovery_window_in_days = 0
tags = var.tags
kms_key_id = var.kms_key_arn
}

resource "aws_secretsmanager_secret_rotation" "key_pair" {
secret_id = aws_secretsmanager_secret.key_pair.id
rotation_lambda_arn = module.rotation.lambda_arn

rotation_rules {
automatically_after_days = var.key_pair_rotation_period_days
}

# Secrets manager requires the access to the rotation lambda to be applied
depends_on = [
aws_lambda_permission.allow_secrets_manager
]
}
30 changes: 30 additions & 0 deletions infra/terraform/modules/_auth/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
variable "release_version" {
description = "The name of the GitHub release version to deploy"
type = string
}

variable "name" {
description = "A name for the AWS resources created by this module"
type = string
}

variable "tags" {
description = "Tags to add to each resource"
type = map(string)
}

variable "package_url" {
description = "The URL of the Lambda authentication function package"
type = string
}

variable "key_pair_rotation_period_days" {
description = "The number of days between automatic scheduled rotations of the key pair"
type = number
}

variable "kms_key_arn" {
description = "The ARN of the KMS key used to encrypt the key pair"
type = string
default = null
}
52 changes: 52 additions & 0 deletions infra/terraform/modules/_lambda/iam.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
data "aws_caller_identity" "current" {}

data "aws_region" "current" {}

data "aws_iam_policy_document" "assume_role" {
statement {
actions = ["sts:AssumeRole"]

principals {
type = "Service"
identifiers = var.lambda_at_edge ? ["lambda.amazonaws.com", "edgelambda.amazonaws.com"] : ["lambda.amazonaws.com"]
}
}
}

data "aws_iam_policy_document" "execution" {
override_policy_documents = var.iam_policy_override_json == null ? [] : [
var.iam_policy_override_json
]

statement {
sid = "logs"

actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]

resources = ["arn:aws:logs:*:*:*"]
}

dynamic "statement" {
for_each = var.kms_key_arn != null ? [var.kms_key_arn] : []
content {
actions = ["kms:Decrypt","kms:GenerateDataKey"]
resources = [ statement.value ]
}
}
}

resource "aws_iam_role" "lambda" {
name = var.name
assume_role_policy = data.aws_iam_policy_document.assume_role.json
tags = var.tags
}

resource "aws_iam_role_policy" "execution" {
name = var.name
role = aws_iam_role.lambda.id
policy = data.aws_iam_policy_document.execution.json
}
32 changes: 32 additions & 0 deletions infra/terraform/modules/_lambda/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
locals {
package_directory = "${path.module}/packages"
package_filename = basename(var.package_url)
}

# Always download the lambda package if it does not exist
resource "null_resource" "download" {
triggers = {
always_run = uuid()
}

provisioner "local-exec" {
command = "test -f ${local.package_directory}/${local.package_filename} || (mkdir -p ${local.package_directory} && wget -P ${local.package_directory} ${var.package_url})"
}
}

resource "aws_lambda_function" "main" {
filename = "${local.package_directory}/${local.package_filename}"
function_name = var.name
role = aws_iam_role.lambda.arn
handler = "index.handler"
source_code_hash = base64sha256(var.package_url)
runtime = "nodejs20.x"
timeout = var.timeout
publish = var.lambda_at_edge
tags = var.tags

# Ensure the lambda function is created after the package is downloaded
depends_on = [
null_resource.download
]
}
4 changes: 4 additions & 0 deletions infra/terraform/modules/_lambda/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "lambda_arn" {
value = var.lambda_at_edge ? aws_lambda_function.main.qualified_arn : aws_lambda_function.main.arn
description = "The Amazon Resource Name (ARN) identifying the Lambda Function Version"
}
10 changes: 10 additions & 0 deletions infra/terraform/modules/_lambda/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Provider passed in should always be us-east-1

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 3.28"
}
}
}
38 changes: 38 additions & 0 deletions infra/terraform/modules/_lambda/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
variable "name" {
description = "A name for the AWS resources created by this module"
type = string
}

variable "tags" {
description = "Tags to add to each resource"
type = map(string)
}

variable "package_url" {
description = "The URL of the function's deployment package"
type = string
}

variable "timeout" {
description = "The amount of time the Lambda function has to run in seconds"
type = number
default = 3
}

variable "iam_policy_override_json" {
description = "An IAM policy document to extend and/or override the default policy document"
type = string
default = null
}

variable "lambda_at_edge" {
description = "Whether the function is to be used with Lambda@Edge"
type = bool
default = false
}

variable "kms_key_arn" {
description = "The ARN of the KMS key used to encrypt the key pair"
type = string
default = null
}
10 changes: 10 additions & 0 deletions infra/terraform/modules/okta_native/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module "auth" {
source = "../_auth"

release_version = var.release_version
name = var.name
tags = var.tags
package_url = "https://github.com/iress/cloudfront-auth/releases/download/${var.release_version}/okta_native.zip"

Choose a reason for hiding this comment

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

link could use an update post merge

key_pair_rotation_period_days = var.key_pair_rotation_period_days
kms_key_arn = var.kms_key_arn
}
4 changes: 4 additions & 0 deletions infra/terraform/modules/okta_native/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "auth_lambda_arn" {
value = module.auth.auth_lambda_arn
description = "The Amazon Resource Name (ARN) identifying the authentication Lambda Function Version"
}
62 changes: 62 additions & 0 deletions infra/terraform/modules/okta_native/parameters.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
resource "aws_ssm_parameter" "base_url" {
name = "/${var.name}/base-url"
type = "String"
value = var.org_url
tags = var.tags
}

resource "aws_ssm_parameter" "client_id" {
name = "/${var.name}/client-id"
type = "String"
value = var.client_id
tags = var.tags
}

resource "aws_ssm_parameter" "domain_name" {
name = "/${var.name}/domain-name"
type = "String"
value = var.domain_name
tags = var.tags
}

resource "aws_ssm_parameter" "callback_path" {
name = "/${var.name}/callback-path"
type = "String"
value = var.callback_path
tags = var.tags
}

resource "aws_ssm_parameter" "logout_path" {
name = "/${var.name}/logout-path"
type = "String"
value = var.logout_path
tags = var.tags
}

resource "aws_ssm_parameter" "session_duration" {
name = "/${var.name}/session-duration"
type = "String"
value = var.session_duration * 60 * 60
tags = var.tags
}

resource "aws_ssm_parameter" "pkce_code_verifier_length" {
name = "/${var.name}/pkce-code-verifier-length"
type = "String"
value = var.pkce_code_verifier_length
tags = var.tags
}

resource "aws_ssm_parameter" "scope" {
name = "/${var.name}/scope"
type = "String"
value = var.scope
tags = var.tags
}

resource "aws_ssm_parameter" "idp" {
name = "/${var.name}/idp"
type = "String"
value = var.idp
tags = var.tags
}
10 changes: 10 additions & 0 deletions infra/terraform/modules/okta_native/provider.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Provider passed in should always be us-east-1

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 3.28"
}
}
}
78 changes: 78 additions & 0 deletions infra/terraform/modules/okta_native/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
variable "release_version" {
description = "The name of the GitHub release version to deploy"
type = string
}

variable "name" {
description = "A name for the AWS resources created by this module"
type = string
}

variable "tags" {
description = "Tags to add to each resource"
type = map(string)
default = {}
}

variable "org_url" {
description = "The org URL for the Okta environment"
type = string
}

variable "client_id" {
description = "Public identifier for the client (can be found in Okta Admin -> Applications)"
type = string
}

variable "domain_name" {
description = "Domain name of the CloudFront distribution"
type = string
}

variable "callback_path" {
description = "The path of the URI where Okta will send OAuth responses"
type = string
default = "/_callback"
}

variable "logout_path" {
description = "The path of the URI where Okta will send OAuth responses"
type = string
default = "/_logout"
}

variable "session_duration" {
description = "The number of hours that the JWT is valid for"
type = number
default = 2
}

variable "pkce_code_verifier_length" {
description = "Length of the cryptographically random string that is used to correlate the authorization request to the token request (from 43 to 128)"
type = number
default = 96
}

variable "key_pair_rotation_period_days" {
description = "The number of days between automatic scheduled rotations of the key pair"
type = number
default = 7
}

variable "scope" {
description = "A space delimited list of OKTA scopes which are used by an application during authentication to authorize access to a user's details, openid is required for authentication requests and other scopes like email may also be included."
type = string
default = "openid email"
}

variable "kms_key_arn" {
description = "The ARN of the KMS key used to encrypt the key pair"
type = string
default = null
}

variable "idp" {
description = "Identity provider to use if there's no Okta Session"
type = string
default = " "
}
30 changes: 30 additions & 0 deletions mocha/custom-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const customConfig = require('../config/custom.config.js');
const assert = require('assert');

describe('Custom configuration', function () {
describe('getConfig', function () {

it('should return an object that has a property matching the value in the file', function (done) {
customConfig.getConfig('./mocha/custom-config.json', 'us-east-1.my-website-auth', function (err, config) {
try {
assert.equal(config.AUTH_REQUEST.redirect_uri, 'http://my-website.com/_callback');
assert(!err);
done();
} catch (err) {
done(err);
}
});
});

it('should return an error when the file is not present', function (done) {
customConfig.getConfig('./mocha/missing.json', 'us-east-1.my-website-auth', function (err, config) {
try {
assert(err);
done();
} catch (err) {
done(err);
}
});
});
});
});
25 changes: 25 additions & 0 deletions mocha/custom-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"AUTH_REQUEST": {
"client_id": "abcdefghijklmnopqrst",
"response_type": "code",
"scope": "openid email",
"redirect_uri": "http://my-website.com/_callback",
"idp": " "
},
"TOKEN_REQUEST": {
"client_id": "0oa1imdxt88mdVZabcdefghijklmnopqrstod0h8",
"redirect_uri": "http://my-website.com/_callback",
"grant_type": "authorization_code"
},
"DISTRIBUTION": "my-website-auth",
"AUTHN": "OKTA_NATIVE",
"PRIVATE_KEY": "my-private-key",
"PUBLIC_KEY": "my-public-key",
"DISCOVERY_DOCUMENT": "http://my-org.okta.com/.well-known/openid-configuration",
"SESSION_DURATION": 7200,
"BASE_URL": "http://my-org.okta.com",
"CALLBACK_PATH": "/_callback",
"LOGOUT_PATH": "/_logout",
"PKCE_CODE_VERIFIER_LENGTH": "96",
"AUTHZ": "OKTA"
}
54 changes: 54 additions & 0 deletions mocha/generic-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const genericConfig = require('../config/generic.config.js');
const assert = require('assert');
const AWS = require('aws-sdk-mock');

describe('Generic configuration', function () {
describe('getConfig', function () {

it('should return an object that has placeholders replaced with values from Parameter Store and Secrets Manager', function (done) {

AWS.mock('SSM', 'getParametersByPath', {
Parameters: [
{
Name: '/my-website-auth/domain-name',
Value: 'my-website.com'
},
{
Name: '/my-website-auth/callback-path',
Value: '/_callback'
}
]
});

AWS.mock('SecretsManager', 'getSecretValue', {
SecretString: JSON.stringify({ 'private-key': 'my-private-key', 'public-key': 'my-public-key' })
});

genericConfig.getConfig('./mocha/generic-config.json', 'us-east-1.my-website-auth', function (err, config) {
try {
assert.equal(config.AUTH_REQUEST.redirect_uri, 'https://my-website.com/_callback');
assert.equal(config.PRIVATE_KEY, 'my-private-key');
assert.equal(config.PUBLIC_KEY, 'my-public-key');
assert(!err);
done();
} catch (err) {
done(err);
} finally {
AWS.restore('SecretsManager');
AWS.restore('SSM');
}
});
});

it('should return an error when the file is not present', function (done) {
genericConfig.getConfig('./mocha/missing.json', 'us-east-1.my-website-auth', function (err, config) {
try {
assert(err);
done();
} catch (err) {
done(err);
}
});
});
});
});
25 changes: 25 additions & 0 deletions mocha/generic-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"AUTH_REQUEST": {
"client_id": "${client-id}",
"response_type": "code",
"scope": "${scope}",
"redirect_uri": "https://${domain-name}${callback-path}",
"idp": "${idp}"
},
"TOKEN_REQUEST": {
"client_id": "${client-id}",
"redirect_uri": "https://${domain-name}${callback-path}",
"grant_type": "authorization_code"
},
"DISTRIBUTION": "okta_native",
"AUTHN": "OKTA_NATIVE",
"PRIVATE_KEY": "${private-key}",
"PUBLIC_KEY": "${public-key}",
"DISCOVERY_DOCUMENT": "${base-url}/.well-known/openid-configuration",
"SESSION_DURATION": "${session-duration}",
"BASE_URL": "${base-url}",
"CALLBACK_PATH": "${callback-path}",
"LOGOUT_PATH": "${logout-path}",
"PKCE_CODE_VERIFIER_LENGTH": "${pkce-code-verifier-length}",
"AUTHZ": "OKTA"
}
58 changes: 58 additions & 0 deletions mocha/rotate-key-pair.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const rotateKeyPair = require('../rotate-key-pair/index.js');
const assert = require('assert');
const AWS = require('aws-sdk-mock');
const sinon = require('sinon');

describe('Rotate key pair', function () {
describe('handler', function () {

it('should put secret value when step is createSecret', function () {
this.timeout(30000);

const putSecretValueSpy = sinon.spy();
AWS.mock('SecretsManager', 'putSecretValue', putSecretValueSpy);

// Call Lambda function handler
const event = {
Step: 'createSecret',
SecretId: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-website-auth/key-pair-ABCDEF',
ClientRequestToken: '123e4567-e89b-12d3-a456-426614174000'
};
rotateKeyPair.handler(event);

// Verify that putSecretValue was called once with the same SecretId and ClientRequestToken
const expectedParams = {
SecretId: event.SecretId,
ClientRequestToken: event.ClientRequestToken
};
sinon.assert.calledOnce(putSecretValueSpy);
sinon.assert.calledWithMatch(putSecretValueSpy, expectedParams);

// Verify that putSecretValue was called once with an RSA key pair as the SecretString
const secret = JSON.parse(putSecretValueSpy.getCall(0).args[0].SecretString);
assert(/^-----BEGIN RSA PRIVATE KEY-----\n[\s\S]*?-----END RSA PRIVATE KEY-----\n$/.test(secret['private-key']));
assert(/^-----BEGIN PUBLIC KEY-----\n[\s\S]*?-----END PUBLIC KEY-----\n$/.test(secret['public-key']));

AWS.restore('SecretsManager');
});

it('should not put secret value when step is not createSecret', function () {
var putSecretValueSpy = sinon.spy();
AWS.mock('SecretsManager', 'putSecretValue', putSecretValueSpy);

// Call Lambda function handler
const event = {
Step: 'setSecret',
SecretId: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-website-auth/key-pair-ABCDEF',
ClientRequestToken: '123e4567-e89b-12d3-a456-426614174000'
};
rotateKeyPair.handler(event);

// Verify that putSecretValue was not called
sinon.assert.notCalled(putSecretValueSpy);

AWS.restore('SecretsManager');
});

});
});
3,275 changes: 3,191 additions & 84 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -3,21 +3,32 @@
"version": "1.0.0",
"description": "An AWS Cloudfront Lambda@Edge function to authenticate requests using Google Apps, Microsoft, GitHub login, OKTA & Auth0",
"main": "index.js",
"engines": {
"node": ">=14"
},
"scripts": {
"test": "cd tests && npm install && cd .. && node tests/tests.js",
"build": "npm install && cd build && npm install && cd .. && node build/build.js"
"test-ci": "npm ci && mocha './mocha/*.js'",
"build": "npm install && cd build && npm install && cd .. && node build/build.js",
"build-ci": "npm ci --production && cd build && npm ci --production && cd .. && node build/build.js"
},
"author": "Widen Enterprises",
"repository": "github:widen/cloudfront-auth",
"license": "ISC",
"dependencies": {
"axios": "^0.18.1",
"axios": "^0.21.1",
"cookie": "^0.3.1",
"crypto": "^1.0.1",
"jsonwebtoken": "^8.1.0",
"es6-dynamic-template": "^2.0.0",
"html-entities": "^2.4.0",
"jsonwebtoken": "^9.0.2",
"jwk-to-pem": "^1.2.6",
"nonce": "^1.0.4",
"querystring": "^0.2.0",
"url": "^0.11.0"
},
"devDependencies": {
"aws-sdk-mock": "^5.8.0",
"mocha": "^9.2.2",
"sinon": "^9.0.2"
}
}
35 changes: 35 additions & 0 deletions rotate-key-pair/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const { generateKeyPairSync } = require('crypto');
const aws = require('aws-sdk');

exports.handler = (event) => {
if (event.Step == "createSecret") {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem'
}
});

const secretsmanager = new aws.SecretsManager({ region: 'us-east-1' });

const params = {
SecretId: event.SecretId,
ClientRequestToken: event.ClientRequestToken,
SecretString: JSON.stringify({ 'private-key': privateKey, 'public-key': publicKey })
};

secretsmanager.putSecretValue(params, function (err, data) {
if (err) {
console.log(`Failed to rotate key pair for secret ${event.SecretId}`);
console.log(err);
} else {
console.log(`Successfully rotated key pair for secret ${event.SecretId}`);
}
});
}
};
2 changes: 1 addition & 1 deletion template.yaml
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@ Resources:
Properties:
CodeUri: distributions/{distribution_name}/{distribution_name}.zip
Role: !GetAtt LambdaEdgeFunctionRole.Arn
Runtime: nodejs10.x
Runtime: nodejs20.x
Handler: index.handler
Timeout: 5
AutoPublishAlias: LIVE
1,577 changes: 1,362 additions & 215 deletions tests/package-lock.json

Large diffs are not rendered by default.