-
Notifications
You must be signed in to change notification settings - Fork 150
Externalise configuration and secrets #70
base: master
Are you sure you want to change the base?
Changes from all commits
c7be318
ff883df
dd7fd03
a228ede
655da6c
fb89b40
36bec4c
748a756
280de0b
c1bade0
1d83d49
c0941d5
5eba0d3
ac736eb
30d2dd8
4785c61
30ef2cc
18b00ac
912a036
16abdc5
9bd02c2
70bbe91
57af25c
863ec52
2b9e826
89df7c3
3946160
5c9c396
699dcf8
0fb3237
c6c30b5
51ee344
21b2496
cd16605
9ca91fb
505eebe
b1af407
095c614
f4c0e44
8455c15
d9e32aa
6fc36a2
c5fc6c4
801f50d
7d0c091
35b12be
bf49023
d1a2225
fee9836
ac267da
360ada9
c84be23
888a212
abc09fa
d34734d
60915a5
d46dbbe
0d6d923
185bfdb
1d9f87a
4454871
183bf6b
483f7e1
59cdf41
5824dc9
f051484
03efeef
bdd68f0
c99c48d
fdcbd1f
15d51f8
2cfa8ec
9d1505a
78fdd1a
ab8b10a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
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 "$@" |
Large diffs are not rendered by default.
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); | ||
}); | ||
}; |
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); | ||
}); | ||
}; |
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. | ||
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 | ||
} | ||
} |
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> |
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" | ||
} |
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" | ||
} |
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 | ||
} |
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 | ||
} |
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" | ||
} |
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" | ||
} |
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" | ||
} |
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" | ||
} |
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" | ||
} | ||
} | ||
} |
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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 | ||
] | ||
} |
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 | ||
} |
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 | ||
} |
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 | ||
] | ||
} |
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" | ||
} |
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" | ||
} | ||
} | ||
} |
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 | ||
} |
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
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" | ||
} |
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 | ||
} |
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" | ||
} | ||
} | ||
} |
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 = " " | ||
} |
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); | ||
} | ||
}); | ||
}); | ||
}); | ||
}); |
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" | ||
} |
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); | ||
} | ||
}); | ||
}); | ||
}); | ||
}); |
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" | ||
} |
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'); | ||
}); | ||
|
||
}); | ||
}); |
Large diffs are not rendered by default.
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}`); | ||
} | ||
}); | ||
} | ||
}; |
Large diffs are not rendered by default.
There was a problem hiding this comment.
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