├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SERVERLESS-REPO.md ├── doc ├── overview.drawio └── overview.svg ├── example-serverless-app-reuse ├── README.md ├── reuse-auth-only.yaml ├── reuse-complete-cdk.ts ├── reuse-complete.yaml └── reuse-with-existing-user-pool.yaml ├── package-lock.json ├── package.json ├── src ├── cfn-custom-resources │ ├── client-secret-retrieval │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ ├── fetch-jwks │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── https.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ ├── generate-secret │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ ├── lambda-code-update │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── https.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ ├── react-app │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── react-app │ │ │ ├── .gitignore │ │ │ ├── .npmignore │ │ │ ├── README.md │ │ │ ├── package-lock.json │ │ │ ├── package.json │ │ │ ├── public │ │ │ ├── aws_sam_introduction.png │ │ │ ├── favicon.ico │ │ │ └── index.html │ │ │ └── src │ │ │ ├── App.css │ │ │ ├── App.js │ │ │ ├── index.css │ │ │ └── index.js │ ├── static-site │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ └── pages │ │ │ ├── index.html │ │ │ └── styles.css │ ├── us-east-1-lambda-stack │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── https.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ ├── user-pool-client │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json │ └── user-pool-domain │ │ ├── .npmignore │ │ ├── cfn-response.ts │ │ ├── index.ts │ │ ├── package-lock.json │ │ └── package.json └── lambda-edge │ ├── check-auth │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json │ ├── http-headers │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json │ ├── parse-auth │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json │ ├── refresh-auth │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json │ ├── rewrite-trailing-slash │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json │ ├── shared │ ├── error-page │ │ ├── html.d.ts │ │ └── template.html │ ├── https.ts │ └── shared.ts │ └── sign-out │ ├── .npmignore │ ├── index.ts │ ├── package-lock.json │ └── package.json ├── template.yaml ├── tsconfig.json └── webpack.config.js /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _Issue #, if available:_ 2 | 3 | _Description of changes:_ 4 | 5 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,node,linux,windows 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### Node ### 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # Runtime data 28 | pids 29 | *.pid 30 | *.seed 31 | *.pid.lock 32 | 33 | # Directory for instrumented libs generated by jscoverage/JSCover 34 | lib-cov 35 | 36 | # Coverage directory used by tools like istanbul 37 | coverage 38 | 39 | # nyc test coverage 40 | .nyc_output 41 | 42 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | bower_components 47 | 48 | # node-waf configuration 49 | .lock-wscript 50 | 51 | # Compiled binary addons (http://nodejs.org/api/addons.html) 52 | build/Release 53 | 54 | # Dependency directories 55 | node_modules/ 56 | jspm_packages/ 57 | 58 | # Typescript v1 declaration files 59 | typings/ 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional REPL history 68 | .node_repl_history 69 | 70 | # Output of 'npm pack' 71 | *.tgz 72 | 73 | # Yarn Integrity file 74 | .yarn-integrity 75 | 76 | # dotenv environment variables file 77 | .env 78 | 79 | 80 | ### OSX ### 81 | *.DS_Store 82 | .AppleDouble 83 | .LSOverride 84 | 85 | # Icon must end with two \r 86 | Icon 87 | 88 | # Thumbnails 89 | ._* 90 | 91 | # Files that might appear in the root of a volume 92 | .DocumentRevisions-V100 93 | .fseventsd 94 | .Spotlight-V100 95 | .TemporaryItems 96 | .Trashes 97 | .VolumeIcon.icns 98 | .com.apple.timemachine.donotpresent 99 | 100 | # Directories potentially created on remote AFP share 101 | .AppleDB 102 | .AppleDesktop 103 | Network Trash Folder 104 | Temporary Items 105 | .apdisk 106 | 107 | ### Windows ### 108 | # Windows thumbnail cache files 109 | Thumbs.db 110 | ehthumbs.db 111 | ehthumbs_vista.db 112 | 113 | # Folder config file 114 | Desktop.ini 115 | 116 | # Recycle Bin used on file shares 117 | $RECYCLE.BIN/ 118 | 119 | # Windows Installer files 120 | *.cab 121 | *.msi 122 | *.msm 123 | *.msp 124 | 125 | # Windows shortcuts 126 | *.lnk 127 | 128 | 129 | # End of https://www.gitignore.io/api/osx,node,linux,windows 130 | 131 | *.js 132 | !*.config.js 133 | 134 | packaged.yaml 135 | dist 136 | .vscode 137 | stats.json 138 | setenvs.sh 139 | .aws-sam 140 | build 141 | *.LICENSE 142 | *.LICENSE.* 143 | CD.sh 144 | PUB.sh 145 | CREATE.sh 146 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | 3 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 4 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 5 | opensource-codeofconduct@amazon.com with any additional questions or comments. 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | ## Reporting Bugs/Feature Requests 10 | 11 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 12 | 13 | When filing an issue, please check [existing open](https://github.com/aws-samples/cloudfront-authorization-at-edge/issues), or [recently closed](https://github.com/aws-samples/cloudfront-authorization-at-edge/issues?utf8=%E2%9C%93&q=is%3Aissue%20is%3Aclosed%20), issues to make sure somebody else hasn't already 14 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 15 | 16 | - A reproducible test case or series of steps 17 | - The version of our code being used 18 | - Any modifications you've made relevant to the bug 19 | - Anything unusual about your environment or deployment 20 | 21 | ## Contributing via Pull Requests 22 | 23 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 24 | 25 | 1. You are working against the latest source on the _master_ branch. 26 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 27 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 28 | 29 | To send us a pull request, please: 30 | 31 | 1. Fork the repository. 32 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 33 | 3. Ensure local tests pass. 34 | 4. Commit to your fork using clear commit messages. 35 | 5. Send us a pull request, answering any default questions in the pull request interface. 36 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 37 | 38 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 39 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 40 | 41 | ## Finding contributions to work on 42 | 43 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any ['help wanted'](https://github.com/aws-samples/cloudfront-authorization-at-edge/labels/help%20wanted) issues is a great place to start. 44 | 45 | ## Code of Conduct 46 | 47 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 48 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 49 | opensource-codeofconduct@amazon.com with any additional questions or comments. 50 | 51 | ## Security issue notifications 52 | 53 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 54 | 55 | ## Licensing 56 | 57 | See the [LICENSE](https://github.com/aws-samples/cloudfront-authorization-at-edge/blob/master/LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 58 | 59 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## CloudFront authorization@edge 2 | 3 | This repo accompanies the [blog post](https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-using-cookies-protect-your-amazon-cloudfront-content-from-being-downloaded-by-unauthenticated-users/). 4 | 5 | In that blog post a solution is explained, that puts **Cognito** authentication in front of (S3) downloads from **CloudFront**, using **Lambda@Edge**. **JWTs** are transferred using **cookies** to make authorization transparent to clients. 6 | 7 | The sources in this repo implement that solution. 8 | 9 | The purpose of this sample code is to demonstrate how Lambda@Edge can be used to implement authorization, with Cognito as identity provider (IDP). Please treat the code as an _**illustration**_––thoroughly review it and adapt it to your needs, if you want to use it for serious things. 10 | 11 | ### TL;DR 12 | 13 | ![Architecture](./doc/overview.svg) 14 | 15 | (More detailed diagrams and explanation in the [blog post](https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-using-cookies-protect-your-amazon-cloudfront-content-from-being-downloaded-by-unauthenticated-users/)) 16 | 17 | ### How to deploy 18 | 19 | The solution can be deployed to your AWS account with a few clicks, from the [Serverless Application Repository](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge). 20 | 21 | More deployment options below: [Deploying the solution](#deploying-the-solution) 22 | 23 | ### Alternative: use HTTP headers 24 | 25 | This repo is the "sibling" of another repo here on aws-samples ([authorization-lambda-at-edge](https://github.com/aws-samples/authorization-lambda-at-edge)). The difference is that the solution in that repo uses http headers (not cookies) to transfer JWTs. While also a valid approach, the downside of it is that your Web App (SPA) needs to be altered to pass these headers, as browsers do not send these along automatically (which they do for cookies). 26 | 27 | ### Alternative: build an Auth@Edge solution yourself, using NPM library [cognito-at-edge](https://github.com/awslabs/cognito-at-edge) 28 | 29 | The repo here contains a complete Auth@Edge solution, i.e. predefined Lambda@Edge code, combined with a CloudFormation template and various CloudFormation custom resources that enable one-click deployment. This CloudFormation template has various parameters, to support multiple use cases (e.g. bring your own User Pool or CloudFront distribution). 30 | 31 | You may want to have full control and implement an Auth@Edge solution yourself. In that case, the NPM library [cognito-at-edge](https://github.com/awslabs/cognito-at-edge), may be of use to you. It implements the same functionalities as the solution here, but wrapped conveniently in an NPM package, that you can easily include in your Lambda@Edge functions. 32 | 33 | ## Repo contents 34 | 35 | This repo contains (a.o.) the following files and directories: 36 | 37 | Lambda@Edge functions in [src/lambda-edge](src/lambda-edge): 38 | 39 | - [check-auth](src/lambda-edge/check-auth): Lambda@Edge function that checks each incoming request for valid JWTs in the request cookies 40 | - [parse-auth](src/lambda-edge/parse-auth): Lambda@Edge function that handles the redirect from the Cognito hosted UI, after the user signed in 41 | - [refresh-auth](src/lambda-edge/refresh-auth): Lambda@Edge function that handles JWT refresh requests 42 | - [sign-out](src/lambda-edge/sign-out): Lambda@Edge function that handles sign-out 43 | - [http-headers](src/lambda-edge/http-headers): Lambda@Edge function that sets HTTP security headers (as good practice) 44 | - [rewrite-trailing-slash](src/lambda-edge/rewrite-trailing-slash): Lambda@Edge function that appends "index.html" to paths that end with a slash (optional use, intended for static site hosting, controlled via parameter `RewritePathWithTrailingSlashToIndex`, see below) 45 | - [shared](src/lambda-edge/shared): Utility functions used by several Lambda@Edge functions 46 | 47 | CloudFormation custom resources in [src/cfn-custom-resources](src/cfn-custom-resources): 48 | 49 | - [us-east-1-lambda-stack](src/cfn-custom-resources/us-east-1-lambda-stack): Lambda function that implements a CloudFormation custom resource that makes sure the Lambda@Edge functions are deployed to us-east-1 (which is a CloudFront requirement, see below.) 50 | - [react-app](src/cfn-custom-resources/react-app): A sample React app that is protected by the solution. It uses AWS Amplify Framework to read the JWTs from cookies. The directory also contains a Lambda function that implements a CloudFormation custom resource to build the React app and upload it to S3 51 | - [static-site](src/cfn-custom-resources/static-site): A sample static site (see [SPA mode or Static Site mode?](#spa-mode-or-static-site-mode)) that is protected by the solution. The directory also contains a Lambda function that implements a CloudFormation custom resource to upload the static site to S3 52 | - [user-pool-client](src/cfn-custom-resources/user-pool-client): Lambda function that implements a CloudFormation custom resource to update the User Pool client with OAuth config 53 | - [user-pool-domain](src/cfn-custom-resources/user-pool-domain): Lambda function that implements a CloudFormation custom resource to lookup the User Pool's domain, at which the Hosted UI is available 54 | - [lambda-code-update](src/cfn-custom-resources/lambda-code-update): Lambda function that implements a CloudFormation custom resource to inject configuration into the lambda@Edge functions and publish versions 55 | - [generate-secret](src/cfn-custom-resources/generate-secret): Lambda function that implements a CloudFormation custom resource that generates a unique secret upon deploying 56 | 57 | Other files and directories: 58 | 59 | - [./example-serverless-app-reuse](./example-serverless-app-reuse): Contains example SAM templates and CDK code that shows how to reuse this application your own SAM or CDK templates. 60 | - [./template.yaml](./template.yaml): The SAM template that comprises the solution 61 | - [./webpack.config.js](./webpack.config.js): Webpack config for the Lambda@Edge functions 62 | - [./tsconfig.json](./tsconfig.json): TypeScript configuration for this project 63 | 64 | ## Deploying the solution 65 | 66 | ### Option 1: Deploy through the Serverless Application Repository 67 | 68 | The solution can be deployed with a few clicks from the [Serverless Application Repository](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge). 69 | 70 | ### Option 2: Deploy by including the Serverless Application in your own CloudFormation template or CDK code 71 | 72 | See [./example-serverless-app-reuse](./example-serverless-app-reuse) 73 | 74 | ### Option 3: Deploy with SAM CLI 75 | 76 | #### Pre-requisites 77 | 78 | 1. Download and install [Node.js](https://nodejs.org/en/download/) 79 | 2. Download and install [AWS SAM CLI](https://github.com/awslabs/aws-sam-cli) 80 | 3. Of course you need an AWS account and necessary permissions to create resources in it. Make sure your AWS credentials can be found during deployment, e.g. by making your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY available as environment variables. 81 | 4. You need an existing S3 bucket to use for the SAM deployment. Create an empty bucket. 82 | 5. Ensure your system includes a Unix-like shell such as sh, bash, zsh, etc. (i.e. Windows users: please enable/install "Linux Subsystem for Windows" or Cygwin or something similar) 83 | 84 | #### Deployment 85 | 86 | NOTE: Run the deployment commands below in a Unix-like shell such as sh, bash, zsh, etc. (i.e. Windows users: please run this in "Linux Subsystem for Windows" or in Cygwin or something similar) 87 | 88 | 1. Clone this repo `git clone https://github.com/aws-samples/cloudfront-authorization-at-edge && cd cloudfront-authorization-at-edge` 89 | 2. Install dependencies: `npm install` 90 | 3. TypeScript compile and run Webpack: `npm run build` 91 | 4. Run SAM build. `sam build` 92 | 5. Run SAM package: `sam package --output-template-file packaged.yaml --s3-bucket ` 93 | 6. Run SAM deploy: `sam deploy --s3-bucket --stack-name --capabilities CAPABILITY_IAM --parameter-overrides EmailAddress=` 94 | 95 | Providing an email address (as above in step 6) is optional. If you provide it, a user will be created in the Cognito User Pool that you can sign-in with. 96 | 97 | ### Option 4: Deploy as is, then test a custom application 98 | 99 | You may want to see how your existing application works with the authentication framework before investing the effort to integrate or automate. One approach involves creating a full deploy from one of the deploy options above, then dropping your application into the bucket that's created. There are a few points to be aware of: 100 | 101 | - If you want your application to load by default instead of the sample REACT single page app (SPA), you'll need to rename the sample REACT's `index.html` and ensure your SPA entry page is named `index.html`. The renamed sample REACT's page will still work when specifically addressed in a URL. 102 | - It's also fine to let your SPA have its own page name, but you'll need to remember to test with its actual URL, e.g. if you drop your SPA entry page into the bucket as `myapp.html` your test URL will look like `https://SOMECLOUDFRONTURLSTRING.cloudfront.net/myapp.html` 103 | - Make sure none of your SPA filenames collide with the REACT app. Alternately just remove the REACT app first -- but sometimes it's nice to keep it in place to validate that authentication is generally working. 104 | 105 | You may find that your application does not render properly -- the default Content Security Policy (CSP) in the CloudFormation parameter may be the issue. As a quick test you can either remove the `"Content-Security-Policy":"..."` parameter from the CloudFormation's HttpHeaders parameter, or substitute your own. Leave the other headers in the parameter alone unless you have a good reason. 106 | 107 | ## I already have a CloudFront distribution, I just want to add auth 108 | 109 | Deploy the solution (e.g. from the [Serverless Application Repository](https://console.aws.amazon.com/lambda/home#/create/app?applicationId=arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge)) while setting parameter `CreateCloudFrontDistribution` to `false`. This way, only the Lambda@Edge functions will de deployed in your account. You'll also get a User Pool and Client (unless you're [bringing your own](#i-already-have-a-cognito-user-pool-i-want-to-reuse-that-one)). Then you can wire the Lambda@Edge functions up into your own CloudFront distribution. Create a behavior for all path patterns (root, RedirectPathSignIn, RedirectPathSignOut, RedirectPathAuthRefresh, SignOutUrl) and configure the corresponding Lambda@Edge function in each behavior. 110 | 111 | The CloudFormation Stack's Outputs contain the Lambda Version ARNs that you can refer to in your CloudFront distribution. 112 | 113 | See this example on how to do it: [./example-serverless-app-reuse/reuse-auth-only.yaml](./example-serverless-app-reuse/reuse-auth-only.yaml) 114 | 115 | When following this route, also provide parameter `AlternateDomainNames` upon deploying, so the correct redirect URL's can be configured for you in the Cognito User Pool Client. 116 | 117 | ## I already have an S3 bucket, I want to use that one 118 | 119 | You can use a pre-existing S3 bucket (e.g. from another region) by specifying the bucket's regional endpoint domain in the parameter `S3OriginDomainName`. An Origin Access Control will automatically be configured for the CloudFront distribution. We recommend applying an S3 bucket policy that restricts requests only from CloudFront, such as: 120 | 121 | ``` 122 | { 123 | "Version": "2012-10-17", 124 | "Statement": [ 125 | { 126 | "Sid": "AllowCloudFrontServicePrincipal", 127 | "Effect": "Allow", 128 | "Principal": { 129 | "Service": "cloudfront.amazonaws.com" 130 | }, 131 | "Action": "s3:GetObject", 132 | "Resource": "arn:aws:s3:::/*", 133 | "Condition": { 134 | "StringEquals": { 135 | "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/" 136 | } 137 | } 138 | } 139 | ] 140 | } 141 | ``` 142 | 143 | Alternatively, go for the more barebone deployment, so you can do more yourself––i.e. reuse your bucket. Refer to scenario: [I already have a CloudFront distribution, I just want to add auth](#i-already-have-a-cloudfront-distribution-i-just-want-to-add-auth). 144 | 145 | ## I want to use another origin behind the CloudFront distribution 146 | 147 | You can use a pre-existing HTTPS origin (e.g. https://example.com), by providing the origins domain name (e.g. example.com) through parameter "CustomOriginDomainName" upon deploying. If you want to make sure requests to your origin come from this CloudFront distribution only (you probably do), configure a secret HTTP header that your custom origin can check for, through parameters "CustomOriginHeaderName" and "CustomOriginHeaderValue". 148 | 149 | Alternatively, go for the more barebone deployment, so you can do more yourself––i.e. bring your own origins. Refer to scenario: [I already have a CloudFront distribution, I just want to add auth](#i-already-have-a-cloudfront-distribution-i-just-want-to-add-auth). 150 | 151 | ## I already have a Cognito User Pool, I want to reuse that one 152 | 153 | You can use a pre-existing Cognito User Pool (e.g. from another region), by providing the User Pool's ARN as a parameter upon deploying. Make sure you have already configured the User Pool with a domain for the Cognito Hosted UI. In this case, also specify a pre-existing User Pool Client ID. 154 | 155 | If the pre-existing User Pool is in the same AWS account, the solution's callback URLs wil be added to the User Pool Client you provide automatically. Also, the User Pool's domain and the Client's secret (in static site mode only) will automatically be looked up. 156 | 157 | If the pre-existing User Pool is in another AWS account: 158 | 159 | - Also specify parameter `UserPoolAuthDomain`, with the domain name of the existing User Pool, e.g. `my-domain-name.auth..amazoncognito.com` 160 | - Also specify parameter `UserPoolClientSecret` (only needed if `EnableSPAMode` is set to `false`, i.e. for static site mode) 161 | - Make sure to add the redirect URIs to the pre-existing User Pool Client in the other account, otherwise users won't be able to log in ("redirect mismatch"). The redirect URIs you'll need to enter are: 162 | - For callback URL: `https://${domain-name-of-your-cloudfront-distribution}${value-you-specified-for-RedirectPathSignIn-parameter}` 163 | - For sign-out URL: `https://${domain-name-of-your-cloudfront-distribution}${value-you-specified-for-RedirectPathSignOut-parameter}` 164 | - Ensure the existing User Pool Client is configured to allow the scopes you provided for parameter `OAuthScopes` 165 | 166 | ## I want to use a social identity provider 167 | 168 | You should use the UserPoolGroupName parameter, to specify a group that users must be a member of in order to access the site. 169 | 170 | Without this UserPoolGroupName, the lambda@edge functions will allow any confirmed user in the User Pool access to the site. 171 | When an identity provider is added to the User Pool, anybody that signs in though the identity provider is immediately a confirmed user. 172 | So with a social identity provider where anyone can create an account, this means anyone can access the site you are trying to protect. 173 | 174 | With the UserPoolGroupName parameter defined, you will need to add each user to this group before they can access the site. 175 | 176 | If the solution is creating the User Pool, it will create the User Pool Group too. 177 | If the solution is creating the User Pool and a default user (via the EmailAddress parameter), then this user will be added User Pool Group. 178 | 179 | If you are using a pre-existing User Pool, you will need to make a group that has a name matching the UserPoolGroupName. 180 | 181 | ## Deployment region 182 | 183 | You can deploy this solution to any AWS region of your liking (that supports the services used). If you choose a region other than us-east-1, this solution will automaticaly create a second CloudFormation stack in us-east-1, for the Lambda@Edge functions. This is because Lambda@Edge must be deployed to us-east-1, this is a CloudFront requirement. Note though that this is a deployment concern only (which the solution handles automatically for you), Lambda@Edge will run in all [Points of Presence](https://aws.amazon.com/cloudfront/features/#Amazon_CloudFront_Infrastructure) globally. 184 | 185 | ## SPA mode or Static Site mode? 186 | 187 | The default deployment mode of this sample application is "SPA mode" - which entails some settings that make the deployment suitable for hosting a SPA such as a React/Angular/Vue app: 188 | 189 | - The User Pool client does not use a client secret, as that would not make sense for JavaScript running in the browser 190 | - The cookies with JWTs are not "http only", so that they can be read and used by the SPA (e.g. to display the user name, or to refresh tokens) 191 | - 404's (page not found on S3) will return index.html, to enable SPA-routing 192 | 193 | If you do not want to deploy a SPA but rather a static site, then it is more secure to use a client secret and http-only cookies. Also, SPA routing is not needed then. To this end, upon deploying, set parameter `EnableSPAMode` to false (`--parameter-overrides EnableSPAMode="false"`). This will: 194 | 195 | - Enforce use of a client secret 196 | - Set cookies to be http only by default (unless you've provided other cookie settings explicitly) 197 | - Skip deployment of the sample React app. Rather a sample index.html is uploaded, that you can replace with your own pages 198 | - Skip setting up the custom error document mapping 404's to index.html (404's will instead show the plain S3 404 page) 199 | - Set the refresh token's path explicitly to the refresh path, `"/refreshauth"` instead of `"/"` (unless you've provided other cookie settings explicitly), and thus the refresh token will not be sent to other paths (more secure and more performant) 200 | 201 | In case you're choosing Static Site mode, it might make sense to set parameter `RewritePathWithTrailingSlashToIndex` to `true` (`--parameter-overrides RewritePathWithTrailingSlashToIndex="true"`). This will append `index.html` to all paths that include a trailing slash, so that e.g. when the user goes to `/some/sub/dir/`, this is translated to `/some/sub/dir/index.html` in the request to S3. 202 | 203 | ## Deploying changes to the react-app or static-site 204 | 205 | To deploy changes to the [react-app](src/cfn-custom-resources/react-app) or [static-site](src/cfn-custom-resources/static-site) after successful inital deployment, you'll need to upload your react-app or static-site changes directly to the S3 bucket (with a utility like [s3-spa-upload](https://www.npmjs.com/package/s3-spa-upload)). Making changes to the code only and re-deploying with SAM will not pick up those code changes to be deployed to the S3 bucket. See [Issue # 96](https://github.com/aws-samples/cloudfront-authorization-at-edge/issues/96) for an alternative to force your code changes to deploy. 206 | 207 | ## Cookie compatibility 208 | 209 | The cookies that this solution sets, are compatible with AWS Amplify––which makes this solution work seamlessly with AWS Amplify. 210 | 211 | _Niche use case:_ 212 | If you want to use this solution as an Auth@Edge layer in front of AWS Elasticsearch Service with Cognito integration, you need cookies to be compatible with the cookie-naming scheme of that service. In that case, upon deploying, set parameter CookieCompatibilty to "elasticsearch". 213 | 214 | If choosing compatibility with AWS Elasticsearch with Cognito integration: 215 | 216 | - Set parameter EnableSPAMode to "false", because AWS Elasticsearch Cognito integration uses a client secret. 217 | - Set parameters UserPoolArn and UserPoolClientId to the ARN and ID of the pre-existing User Pool and Client, that you've configured your Elasticsearch domain with. 218 | 219 | ## Additional Cookies 220 | 221 | You can provide one or more additional cookies that will be set after succesfull sign-in, by setting the parameter AdditionalCookies. This may be of use to you, to dynamically provide configuration that you can read in your SPA's JavaScript. 222 | 223 | ## Accessing Lambda@Edge function logs 224 | 225 | The easiest way to locate the right log group and the right region, is to use the CloudFront monitoring dashboard (https://console.aws.amazon.com/cloudfront/v4/home#/monitoring) and navigate to the lambda function logs in the right region, from there. 226 | 227 | ### Explanation 228 | 229 | Accessing Lambda@Edge function logs is different from regular Lambda functions. Assuming a regular lambda function with the name `abc`, it would normally write to log group `/aws/lambda/abc` in the same region as the Lambda function--but this is not so for Lambda@Edge functions. For Lambda@Edge functions the log group will be in the region where the Lambda@Edge function was executed (which can be any region on the Globe), and will have a name like so: `/aws/lambda/us-east-1.abc` (so regardless of actual region, the log group name starts with `/aws/lambda/us-east-1.` followed by the name of the function). For Lambda@Edge functions, the button in the Lambda UI that takes you to the log group would always show you "The specified log group does not exist", as that button would take you to e.g. log group `/aws/lambda/abc`. 230 | 231 | ## Deleting the stack 232 | 233 | When deleting the stack in the normal way, some of the Lambda@Edge functions may end up in DELETE_FAILED state, with an error similar to this: 234 | 235 | ``` 236 | An error occurred (InvalidParameterValueException) when calling the DeleteFunction operation: Lambda was unable to delete arn:aws:lambda:us-east-1:12345:function:LambdaFunctionName:1 because it is a replicated function. Please see our documentation for Deleting Lambda@Edge Functions and Replicas. 237 | ``` 238 | 239 | Simply wait a few hours and try the delete of the nested stack again, then it works. 240 | This is a development opportunity in Lambda@Edge and not something we can influence unfortunately: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-delete-replicas.html 241 | 242 | ## Contributing to this repo 243 | 244 | If you want to contribute, please read [CONTRIBUTING](./CONTRIBUTING.md), and note the hints below. 245 | 246 | ### Declaration of npm dependencies 247 | 248 | The sources that are not webpacked but rather run through `sam build` should have their dependencies listed in their own package.json files––to make `sam build` work properly. 249 | 250 | For the sources that are webpacked this doesn't matter. 251 | 252 | ## License Summary 253 | 254 | This sample code is made available under a modified MIT license. See the [LICENSE](./LICENSE) file. 255 | -------------------------------------------------------------------------------- /SERVERLESS-REPO.md: -------------------------------------------------------------------------------- 1 | # Protect downloads of your content hosted on CloudFront with Cognito authentication using Lambda@Edge 2 | 3 | This serverless application accompanies the [blog post](https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-using-cookies-protect-your-amazon-cloudfront-content-from-being-downloaded-by-unauthenticated-users/). 4 | 5 | In that blog post a solution is explained, that puts Cognito authentication in front of (S3) downloads from CloudFront, using Lambda@Edge. JWTs are transferred using cookies to make authorization transparent to clients. 6 | 7 | This application is an implementation of that solution. If you deploy it, this is what you get: 8 | 9 | - Private S3 bucket pre-populated with a sample React app (or static site if you turn SPA mode off). You can replace that sample app with your own Single Page Application (React, Anugular, Vue) or any other static content you want authenticated users to be able to download. 10 | - CloudFront distribution that serves the contents of the S3 bucket 11 | - Cognito User Pool with hosted UI set up 12 | - Lambda@Edge functions that make sure only authenticated users can access your S3 content through CloudFront. Redirect to Cognito Hosted UI to sign-in if necessary. 13 | 14 | If you supply an email address, a user will be created that you can use to sign-in with (a temporary password is sent to the supplied e-mail address) 15 | 16 | To open the web app after successful deployment, navigate to the CloudFormation stack, in the "Outputs" tab, click on the output named: "WebsiteUrl". 17 | 18 | NOTE: If you want to use a pre-existing User Pool, provide the User Pool ARN through the corresponding parameter then. That User Pool must already be configured with a User Pool domain for the Cognito Hosted UI. Also provide a pre-existing User Pool Client ID in this case. 19 | 20 | NOTE: The purpose of this sample application is to demonstrate how Lambda@Edge can be used to implement authorization, with Cognito as identity provider (IDP). Please treat the application as an _**illustration**_––thoroughly review it and adapt it to your needs, if you want to use it for serious things. 21 | -------------------------------------------------------------------------------- /doc/overview.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /example-serverless-app-reuse/README.md: -------------------------------------------------------------------------------- 1 | # Examples that show how to reuse the serverless application in your own CloudFormation/SAM templates or CDK code 2 | 3 | This directory contains the following examples: 4 | 5 | ## [reuse-complete.yaml](./reuse-complete.yaml) 6 | 7 | This examples shows how you can add some users of your own to the serverless application's User Pool. Also it shows how you can access outputs from the serverless application. 8 | 9 | ## [reuse-complete-cdk.ts](./reuse-complete-cdk.ts) 10 | 11 | CDK example, similar to [reuse-complete.yaml](./reuse-complete.yaml) 12 | 13 | ## [reuse-auth-only.yaml](./reuse-auth-only.yaml) 14 | 15 | This example shows how to wire up this solution's auth functions into your own CloudFront distribution. Features include: 16 | 17 | - An example private S3 bucket resource and parameter to name it 18 | - An example functional CloudFront distribution providing access to the bucket by Origin Access Identity 19 | - The nested reused serverless application stack resource illustrating how to pass your template's parameters to the application, in this case to modify http headers 20 | - An example showing how to retrieve output parameters from the nested application stack for use in the outer template 21 | - Parameterized semantic version to allow operation with future versions of the application 22 | - Note the instructions on updating the User Pool client in this example's description. 23 | 24 | ## [reuse-with-existing-user-pool.yaml](./reuse-with-existing-user-pool.yaml) 25 | 26 | This example shows how to reuse the serverless application with a pre-existing User Pool and Client. 27 | 28 | ## CloudFormation/SAM Deployment 29 | 30 | You can deploy the CloudFormation/SAM examples as follows: 31 | 32 | ```sh 33 | #!/bin/sh 34 | 35 | STACK_NAME=my-protected-cloudfront-stack 36 | TEMPLATE=reuse-complete.yaml # Or one of the other ones 37 | 38 | sam deploy --template-file $TEMPLATE --stack-name $STACK_NAME \ 39 | --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND --region us-east-1 40 | 41 | ``` 42 | 43 | or simply launch the sample template in CloudFormation 44 | -------------------------------------------------------------------------------- /example-serverless-app-reuse/reuse-auth-only.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Transform: AWS::Serverless-2016-10-31 6 | Description: > 7 | Example stack to that reuses the auth portion of the serverless application 8 | in your own CloudFront distribution. 9 | 10 | After deployment, manually update the User Pool client to point the 'Callback URL' and 'Sign out URL' to the URL of the CloudFront distribution. 11 | Alternately, add an alternate domain name to the CloudFront distribution and provide the name as parameter to the serverless stack. The alternate 12 | domain will also require adding a certificate to the Cloudfront Distribution. This alternative will set the User Pool client to the right Callback URL 13 | and Sign out URL automatically. 14 | 15 | The sample CloudFront distribution illustrates a full pattern of use -- modify it as needed. 16 | 17 | The HttpHeaders parameter and corresponding reference in the 'LambdaEdgeProtection' nested application Resource 18 | illustrates how to pass information to the underlying SAM app 19 | 20 | Outputs illustrate retrieving values from the underlying SAM application 21 | 22 | Parameters: 23 | BucketNameParameter: 24 | Type: String 25 | Description: A legal bucket name. Must not exist. 26 | 27 | PriceClass: 28 | Type: String 29 | Description: CloudFront price class, e.g. PriceClass_200 for most regions (default), PriceClass_All for all regions (the default), PriceClass_100 least expensive (US, Canada, Europe), or PriceClass_All 30 | Default: PriceClass_200 31 | 32 | SemanticVersion: 33 | Type: String 34 | Description: Semantic version of the back end 35 | Default: 2.3.2 36 | 37 | HttpHeaders: 38 | Type: String 39 | Description: The HTTP headers to set on all responses from CloudFront. Defaults are illustrations only and contain a report-only Cloud Security Policy -- adjust for your application 40 | Default: >- 41 | { 42 | "Content-Security-Policy-Report-Only": "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; connect-src 'self' https://*.amazonaws.com https://*.amazoncognito.com", 43 | "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", 44 | "Referrer-Policy": "same-origin", 45 | "X-XSS-Protection": "1; mode=block", 46 | "X-Frame-Options": "DENY", 47 | "X-Content-Type-Options": "nosniff" 48 | } 49 | 50 | Resources: 51 | ApplicationBucket: 52 | Type: AWS::S3::Bucket 53 | DeletionPolicy: Retain 54 | Properties: 55 | BucketName: 56 | Ref: BucketNameParameter 57 | CloudFrontOriginAccessIdentity: 58 | Type: AWS::CloudFront::CloudFrontOriginAccessIdentity 59 | Properties: 60 | CloudFrontOriginAccessIdentityConfig: 61 | Comment: !Sub "${BucketNameParameter}-OAI" 62 | S3AccessPolicyForOAI: 63 | Type: AWS::S3::BucketPolicy 64 | Properties: 65 | Bucket: 66 | Ref: ApplicationBucket 67 | PolicyDocument: 68 | Version: "2012-10-17" 69 | Statement: 70 | - Effect: Allow 71 | Principal: 72 | CanonicalUser: 73 | Fn::GetAtt: [CloudFrontOriginAccessIdentity, S3CanonicalUserId] 74 | Action: "s3:GetObject" 75 | Resource: !Sub "${ApplicationBucket.Arn}/*" 76 | CloudFrontDistribution: 77 | Type: AWS::CloudFront::Distribution 78 | Properties: 79 | DistributionConfig: 80 | # Aliases: 81 | # ViewerCertificate: 82 | CacheBehaviors: 83 | - PathPattern: /parseauth 84 | Compress: true 85 | ForwardedValues: 86 | QueryString: true 87 | LambdaFunctionAssociations: 88 | - EventType: viewer-request 89 | LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.ParseAuthHandler 90 | TargetOriginId: dummy-origin 91 | ViewerProtocolPolicy: redirect-to-https 92 | - PathPattern: /refreshauth 93 | Compress: true 94 | ForwardedValues: 95 | QueryString: true 96 | LambdaFunctionAssociations: 97 | - EventType: viewer-request 98 | LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.RefreshAuthHandler 99 | TargetOriginId: dummy-origin 100 | ViewerProtocolPolicy: redirect-to-https 101 | - PathPattern: /signout 102 | Compress: true 103 | ForwardedValues: 104 | QueryString: true 105 | LambdaFunctionAssociations: 106 | - EventType: viewer-request 107 | LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.SignOutHandler 108 | TargetOriginId: dummy-origin 109 | ViewerProtocolPolicy: redirect-to-https 110 | DefaultCacheBehavior: 111 | Compress: true 112 | ForwardedValues: 113 | QueryString: true 114 | LambdaFunctionAssociations: 115 | - EventType: viewer-request 116 | LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.CheckAuthHandler 117 | - EventType: origin-response 118 | LambdaFunctionARN: !GetAtt LambdaEdgeProtection.Outputs.HttpHeadersHandler 119 | TargetOriginId: protected-origin 120 | ViewerProtocolPolicy: redirect-to-https 121 | Enabled: true 122 | Origins: 123 | - DomainName: example.org # Dummy origin is used for Lambda@Edge functions, keep this as-is 124 | Id: dummy-origin 125 | CustomOriginConfig: 126 | OriginProtocolPolicy: match-viewer 127 | - DomainName: !GetAtt ApplicationBucket.RegionalDomainName 128 | Id: protected-origin 129 | S3OriginConfig: 130 | OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" 131 | CustomErrorResponses: 132 | - ErrorCode: 404 133 | ResponseCode: 200 134 | ResponsePagePath: /index.html 135 | PriceClass: !Ref PriceClass 136 | DefaultRootObject: index.html 137 | 138 | LambdaEdgeProtection: 139 | Type: AWS::Serverless::Application 140 | Properties: 141 | Location: 142 | ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge 143 | SemanticVersion: !Ref SemanticVersion 144 | Parameters: 145 | CreateCloudFrontDistribution: "false" 146 | HttpHeaders: !Ref HttpHeaders 147 | # AlternateDomainNames: 148 | Outputs: 149 | UserPoolId: 150 | Description: The user pool id to illustrate how to retrieve outputs from the SAM app 151 | Value: !GetAtt LambdaEdgeProtection.Outputs.UserPoolId 152 | -------------------------------------------------------------------------------- /example-serverless-app-reuse/reuse-complete-cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | /** 4 | * CDK Example showing how to deploy the SAR app through CDK, with parameters and outputs 5 | * 6 | * E.g. run CDK synth like so: 7 | * cdk synth --app ./cdk-example.ts 8 | * 9 | * Deployment: 10 | * cdk deploy --app ./cdk-example.ts 11 | */ 12 | 13 | import * as cdk from "@aws-cdk/core"; 14 | import * as sam from "@aws-cdk/aws-sam"; 15 | 16 | const stack = new cdk.Stack(new cdk.App(), "example-cdk-stack"); 17 | 18 | const authAtEdge = new sam.CfnApplication(stack, "AuthorizationAtEdge", { 19 | location: { 20 | applicationId: 21 | "arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge", 22 | semanticVersion: "2.3.2", 23 | }, 24 | parameters: { 25 | EmailAddress: "johndoe@example.com", 26 | }, 27 | }); 28 | 29 | new cdk.CfnOutput(stack, "ProtectedS3Bucket", { 30 | value: authAtEdge.getAtt("Outputs.S3Bucket").toString(), 31 | }); 32 | -------------------------------------------------------------------------------- /example-serverless-app-reuse/reuse-complete.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Transform: AWS::Serverless-2016-10-31 6 | Description: > 7 | Example stack that shows how to reuse the serverless application and include your own resources 8 | 9 | Resources: 10 | MyLambdaEdgeProtectedSpaSetup: 11 | Type: AWS::Serverless::Application 12 | Properties: 13 | Location: 14 | ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge 15 | SemanticVersion: 2.3.2 16 | AlanTuring: 17 | Type: AWS::Cognito::UserPoolUser 18 | Properties: 19 | Username: alan.turing@example.com 20 | UserPoolId: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.UserPoolId 21 | EdsgerDijkstra: 22 | Type: AWS::Cognito::UserPoolUser 23 | Properties: 24 | Username: edgser.dijkstra@example.com 25 | UserPoolId: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.UserPoolId 26 | 27 | Outputs: 28 | MySpaS3Bucket: 29 | Description: The S3 Bucket into which my SPA will be uploaded 30 | Value: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.S3Bucket 31 | -------------------------------------------------------------------------------- /example-serverless-app-reuse/reuse-with-existing-user-pool.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | AWSTemplateFormatVersion: "2010-09-09" 5 | Transform: AWS::Serverless-2016-10-31 6 | Description: > 7 | Example stack that shows how to reuse the serverless application with a pre-existing User Pool and Client. 8 | The pre-existing User Pool Arn and Client ID can be provided to the Auth@Edge application through parameters. 9 | 10 | In this example we're creating the User Pool and Client, and the Auth@Edge application in the same stack in the same region. 11 | You could instead also use a pre-existing User Pool and Client from a different stack and region. 12 | 13 | Parameters: 14 | EnableSPAMode: 15 | Type: String 16 | Description: Set to 'false' to disable SPA-specific features (i.e. when deploying a static site that won't interact with logout/refresh) 17 | Default: "true" 18 | AllowedValues: 19 | - "true" 20 | - "false" 21 | OAuthScopes: 22 | Type: CommaDelimitedList 23 | Description: The OAuth scopes to request the User Pool to add to the access token JWT 24 | Default: "phone, email, profile, openid, aws.cognito.signin.user.admin" 25 | 26 | Conditions: 27 | GenerateClientSecret: !Equals 28 | - EnableSPAMode 29 | - "false" 30 | 31 | Resources: 32 | UserPool: 33 | Type: AWS::Cognito::UserPool 34 | Properties: 35 | UserPoolName: !Ref AWS::StackName 36 | AdminCreateUserConfig: 37 | AllowAdminCreateUserOnly: true 38 | UsernameAttributes: 39 | - email 40 | UserPoolClient: 41 | Type: AWS::Cognito::UserPoolClient 42 | Properties: 43 | UserPoolId: !Ref UserPool 44 | PreventUserExistenceErrors: ENABLED 45 | GenerateSecret: !If 46 | - GenerateClientSecret 47 | - true 48 | - false 49 | AllowedOAuthScopes: !Ref OAuthScopes 50 | AllowedOAuthFlowsUserPoolClient: true 51 | AllowedOAuthFlows: 52 | - code 53 | SupportedIdentityProviders: 54 | - COGNITO 55 | CallbackURLs: 56 | # The following sentinel value will be replaced by Auth@Edge with the CloudFront domain name (if you let Auth@Edge create the CloudFront distribution) 57 | - https://example.com/will-be-replaced 58 | LogoutURLs: 59 | # The following sentinel value will be replaced by Auth@Edge with the CloudFront domain name (if you let Auth@Edge create the CloudFront distribution) 60 | - https://example.com/will-be-replaced 61 | UserPoolDomain: 62 | Type: AWS::Cognito::UserPoolDomain 63 | Properties: 64 | Domain: !Sub 65 | - "auth-${StackIdSuffix}" 66 | - StackIdSuffix: !Select 67 | - 2 68 | - !Split 69 | - "/" 70 | - !Ref AWS::StackId 71 | UserPoolId: !Ref UserPool 72 | MyLambdaEdgeProtectedSpaSetup: 73 | Type: AWS::Serverless::Application 74 | DependsOn: UserPoolDomain 75 | Properties: 76 | Location: 77 | ApplicationId: arn:aws:serverlessrepo:us-east-1:520945424137:applications/cloudfront-authorization-at-edge 78 | SemanticVersion: 2.3.2 79 | Parameters: 80 | UserPoolArn: !GetAtt UserPool.Arn 81 | UserPoolClientId: !Ref UserPoolClient 82 | EnableSPAMode: !Ref EnableSPAMode 83 | CreateCloudFrontDistribution: true 84 | OAuthScopes: !Join 85 | - "," 86 | - !Ref OAuthScopes 87 | Outputs: 88 | WebsiteUrl: 89 | Description: URL of the CloudFront distribution that serves your SPA from S3 90 | Value: !GetAtt MyLambdaEdgeProtectedSpaSetup.Outputs.WebsiteUrl 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudfront-authorization-at-edge", 3 | "version": "2.2.1", 4 | "description": "Protect downloads of your content hosted on CloudFront with Cognito authentication using Lambda@Edge", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Sorry, there aren't any tests\"; exit 1", 8 | "webpack": "webpack --progress", 9 | "analyze": "webpack --profile --json > stats.json && webpack-bundle-analyzer ./stats.json", 10 | "build": "npm run remove-webpack-output && npm run webpack", 11 | "remove-webpack-output": "find src -type f \\( -name 'bundle.js' -o -name '*.bundle.js' \\) -exec rm {} +" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "devDependencies": { 16 | "@types/adm-zip": "^0.4.34", 17 | "@types/aws-lambda": "^8.10.92", 18 | "@types/cookie": "^0.4.1", 19 | "@types/fs-extra": "^9.0.13", 20 | "@types/ncp": "^2.0.8", 21 | "@types/node": "^20.2.5", 22 | "html-loader": "^3.1.0", 23 | "prettier": "^2.5.1", 24 | "terser-webpack-plugin": "^5.3.1", 25 | "ts-loader": "^9.2.6", 26 | "typescript": "^4.5.5", 27 | "webpack": "^5.94.0", 28 | "webpack-bundle-analyzer": "^4.5.0", 29 | "webpack-cli": "^4.9.2" 30 | }, 31 | "dependencies": { 32 | "@tsconfig/node20": "^20.1.2", 33 | "adm-zip": "^0.5.10", 34 | "aws-jwt-verify": "^2.1.3", 35 | "aws-sdk": "^2.1571.0", 36 | "cookie": "^0.7.0", 37 | "ncp": "^2.0.0", 38 | "s3-spa-upload": "^2.1.5" 39 | }, 40 | "prettier": { 41 | "trailingComma": "es5", 42 | "tabWidth": 2 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/client-secret-retrieval/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/client-secret-retrieval/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/client-secret-retrieval/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { CloudFormationCustomResourceHandler } from "aws-lambda"; 5 | import CognitoIdentityServiceProvider from "aws-sdk/clients/cognitoidentityserviceprovider"; 6 | import { sendCfnResponse, Status } from "./cfn-response"; 7 | 8 | async function retrieveClientSecret( 9 | action: "Create" | "Update" | "Delete", 10 | userPoolArn: string, 11 | clientId: string, 12 | physicalResourceId?: string 13 | ) { 14 | if (action === "Delete") { 15 | // Deletes aren't executed; the standard Resource should just be deleted 16 | return { physicalResourceId: physicalResourceId }; 17 | } 18 | const userPoolId = userPoolArn.split("/")[1]; 19 | const userPoolRegion = userPoolArn.split(":")[3]; 20 | const cognitoClient = new CognitoIdentityServiceProvider({ 21 | region: userPoolRegion, 22 | }); 23 | const input: CognitoIdentityServiceProvider.Types.DescribeUserPoolClientRequest = 24 | { 25 | UserPoolId: userPoolId, 26 | ClientId: clientId, 27 | }; 28 | const { UserPoolClient } = await cognitoClient 29 | .describeUserPoolClient(input) 30 | .promise(); 31 | if (!UserPoolClient?.ClientSecret) { 32 | throw new Error( 33 | `User Pool client ${clientId} is not set up with a client secret` 34 | ); 35 | } 36 | return { 37 | physicalResourceId: `${userPoolId}-${clientId}-retrieved-client-secret`, 38 | Data: { ClientSecret: UserPoolClient.ClientSecret }, 39 | }; 40 | } 41 | 42 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 43 | console.log(JSON.stringify(event, undefined, 4)); 44 | const { ResourceProperties, RequestType } = event; 45 | 46 | const { UserPoolArn, UserPoolClientId } = ResourceProperties; 47 | 48 | let status = Status.SUCCESS; 49 | let physicalResourceId: string | undefined; 50 | let data: { [key: string]: any } | undefined; 51 | let reason: string | undefined; 52 | try { 53 | ({ physicalResourceId, Data: data } = await retrieveClientSecret( 54 | RequestType, 55 | UserPoolArn, 56 | UserPoolClientId 57 | )); 58 | } catch (err) { 59 | console.error(err); 60 | status = Status.FAILED; 61 | reason = `${err}`; 62 | } 63 | await sendCfnResponse({ 64 | event, 65 | status, 66 | data, 67 | physicalResourceId, 68 | reason, 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/client-secret-retrieval/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-secret-retrieval", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "client-secret-retrieval", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/client-secret-retrieval/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-secret-retrieval", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/https.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | import { Writable, pipeline } from "stream"; 6 | 7 | export async function fetch(uri: string) { 8 | return new Promise((resolve, reject) => { 9 | const req = request(uri, (res) => 10 | pipeline([res, collectBuffer(resolve)], done) 11 | ); 12 | 13 | function done(error?: Error | null) { 14 | if (!error) return; 15 | req.destroy(error); 16 | reject(error); 17 | } 18 | 19 | req.on("error", done); 20 | 21 | req.end(); 22 | }); 23 | } 24 | 25 | const collectBuffer = (callback: (collectedBuffer: Buffer) => void) => { 26 | const chunks = [] as Buffer[]; 27 | return new Writable({ 28 | write: (chunk, _encoding, done) => { 29 | try { 30 | chunks.push(chunk); 31 | done(); 32 | } catch (err) { 33 | done(err as Error); 34 | } 35 | }, 36 | final: (done) => { 37 | try { 38 | callback(Buffer.concat(chunks)); 39 | done(); 40 | } catch (err) { 41 | done(err as Error); 42 | } 43 | }, 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { 5 | CloudFormationCustomResourceHandler, 6 | CloudFormationCustomResourceDeleteEvent, 7 | CloudFormationCustomResourceUpdateEvent, 8 | } from "aws-lambda"; 9 | import { sendCfnResponse, Status } from "./cfn-response"; 10 | import { fetch } from "./https"; 11 | 12 | async function fetchJwks( 13 | action: "Create" | "Update" | "Delete", 14 | userPoolArn: string, 15 | physicalResourceId?: string 16 | ) { 17 | if (action === "Delete") { 18 | // Deletes aren't executed 19 | return { physicalResourceId: physicalResourceId!, Data: {} }; 20 | } 21 | console.log(`Fetching JWKS for ${userPoolArn}`); 22 | 23 | const match = userPoolArn.match( 24 | new RegExp("userpool/(?.+)_(?.+)$") 25 | ); 26 | if (!match?.groups) { 27 | throw new Error("Failed to parse User Pool ARN"); 28 | } 29 | const url = `https://cognito-idp.${match.groups.region}.amazonaws.com/${match.groups.region}_${match.groups.userPoolId}/.well-known/jwks.json`; 30 | 31 | console.log(`Fetching JWKS from ${url}`); 32 | const jwks = (await fetch(url)).toString(); 33 | console.log(`Fetched JWKS: ${jwks}`); 34 | 35 | return { 36 | physicalResourceId: userPoolArn, 37 | Data: { Jwks: jwks }, 38 | }; 39 | } 40 | 41 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 42 | const { ResourceProperties, RequestType } = event; 43 | 44 | const { PhysicalResourceId } = event as 45 | | CloudFormationCustomResourceDeleteEvent 46 | | CloudFormationCustomResourceUpdateEvent; 47 | 48 | const { UserPoolArn } = ResourceProperties; 49 | 50 | let status = Status.SUCCESS; 51 | let physicalResourceId: string | undefined; 52 | let data: { [key: string]: any } | undefined; 53 | let reason: string | undefined; 54 | try { 55 | ({ physicalResourceId, Data: data } = await fetchJwks( 56 | RequestType, 57 | UserPoolArn, 58 | PhysicalResourceId 59 | )); 60 | } catch (err) { 61 | console.error(err); 62 | status = Status.FAILED; 63 | reason = `${err}`; 64 | } 65 | await sendCfnResponse({ 66 | event, 67 | status, 68 | data, 69 | physicalResourceId, 70 | reason, 71 | }); 72 | }; 73 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-jwks", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "fetch-jwks", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/fetch-jwks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-jwks", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/generate-secret/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/generate-secret/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/generate-secret/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { randomBytes } from "crypto"; 5 | import { 6 | CloudFormationCustomResourceHandler, 7 | CloudFormationCustomResourceDeleteEvent, 8 | CloudFormationCustomResourceUpdateEvent, 9 | } from "aws-lambda"; 10 | import { sendCfnResponse, Status } from "./cfn-response"; 11 | 12 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 13 | console.log(JSON.stringify(event, undefined, 4)); 14 | const { ResourceProperties } = event; 15 | 16 | const { PhysicalResourceId } = event as 17 | | CloudFormationCustomResourceDeleteEvent 18 | | CloudFormationCustomResourceUpdateEvent; 19 | 20 | const { 21 | Length = 16, 22 | AllowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", 23 | } = ResourceProperties; 24 | 25 | let status = Status.SUCCESS; 26 | let physicalResourceId: string | undefined; 27 | let data: { [key: string]: any } | undefined; 28 | let reason: string | undefined; 29 | try { 30 | physicalResourceId = 31 | PhysicalResourceId || 32 | [...new Array(parseInt(Length))] 33 | .map(() => randomChoiceFromIndexable(AllowedCharacters)) 34 | .join(""); 35 | } catch (err) { 36 | console.error(err); 37 | status = Status.FAILED; 38 | reason = `${err}`; 39 | } 40 | await sendCfnResponse({ 41 | event, 42 | status, 43 | data, 44 | physicalResourceId, 45 | reason, 46 | }); 47 | }; 48 | 49 | function randomChoiceFromIndexable(indexable: string) { 50 | if (indexable.length > 256) { 51 | throw new Error(`indexable is too large: ${indexable.length}`); 52 | } 53 | const chunks = Math.floor(256 / indexable.length); 54 | const firstBiassedIndex = indexable.length * chunks; 55 | let randomNumber: number; 56 | do { 57 | randomNumber = randomBytes(1)[0]; 58 | } while (randomNumber >= firstBiassedIndex); 59 | const index = randomNumber % indexable.length; 60 | return indexable[index]; 61 | } 62 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/generate-secret/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate-secret", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "generate-secret", 9 | "version": "1.0.0", 10 | "devDependencies": {} 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/generate-secret/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate-secret", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/https.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | import { Writable, pipeline } from "stream"; 6 | 7 | export async function fetch(uri: string) { 8 | return new Promise((resolve, reject) => { 9 | const req = request(uri, (res) => 10 | pipeline([res, collectBuffer(resolve)], done) 11 | ); 12 | 13 | function done(error?: Error | null) { 14 | if (!error) return; 15 | req.destroy(error); 16 | reject(error); 17 | } 18 | 19 | req.on("error", done); 20 | 21 | req.end(); 22 | }); 23 | } 24 | 25 | const collectBuffer = (callback: (collectedBuffer: Buffer) => void) => { 26 | const chunks = [] as Buffer[]; 27 | return new Writable({ 28 | write: (chunk, _encoding, done) => { 29 | try { 30 | chunks.push(chunk); 31 | done(); 32 | } catch (err) { 33 | done(err as Error); 34 | } 35 | }, 36 | final: (done) => { 37 | try { 38 | callback(Buffer.concat(chunks)); 39 | done(); 40 | } catch (err) { 41 | done(err as Error); 42 | } 43 | }, 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { 5 | CloudFormationCustomResourceHandler, 6 | CloudFormationCustomResourceDeleteEvent, 7 | CloudFormationCustomResourceUpdateEvent, 8 | } from "aws-lambda"; 9 | import Lambda from "aws-sdk/clients/lambda"; 10 | import Zip from "adm-zip"; 11 | import { writeFileSync, mkdtempSync } from "fs"; 12 | import { resolve } from "path"; 13 | import { sendCfnResponse, Status } from "./cfn-response"; 14 | import { fetch } from "./https"; 15 | 16 | async function updateLambdaCode( 17 | action: "Create" | "Update" | "Delete", 18 | lambdaFunction: string, 19 | stringifiedConfig: string, 20 | physicalResourceId?: string 21 | ) { 22 | if (action === "Delete") { 23 | // Deletes aren't executed; the Lambda Resource should just be deleted 24 | return { physicalResourceId: physicalResourceId!, Data: {} }; 25 | } 26 | console.log( 27 | `Adding configuration to Lambda function ${lambdaFunction}:\n${stringifiedConfig}` 28 | ); 29 | const region = lambdaFunction.split(":")[3]; 30 | const lambdaClient = new Lambda({ region }); 31 | // Parse the JSON to ensure it's validity (and avoid ugly errors at runtime) 32 | const config = JSON.parse(stringifiedConfig); 33 | // Fetch and extract Lambda zip contents to temporary folder, add configuration.json, and rezip 34 | const { Code } = await lambdaClient 35 | .getFunction({ 36 | FunctionName: lambdaFunction, 37 | }) 38 | .promise(); 39 | const data = await fetch(Code!.Location!); 40 | const lambdaZip = new Zip(data); 41 | console.log( 42 | "Lambda zip contents:", 43 | lambdaZip.getEntries().map((entry) => entry.name) 44 | ); 45 | console.log("Adding (fresh) configuration.json ..."); 46 | const tempDir = mkdtempSync("/tmp/lambda-package"); 47 | lambdaZip.extractAllTo(tempDir, true); 48 | writeFileSync( 49 | resolve(tempDir, "configuration.json"), 50 | Buffer.from(JSON.stringify(config, null, 2)) 51 | ); 52 | const newLambdaZip = new Zip(); 53 | newLambdaZip.addLocalFolder(tempDir); 54 | console.log( 55 | "New Lambda zip contents:", 56 | newLambdaZip.getEntries().map((entry) => entry.name) 57 | ); 58 | 59 | const { CodeSha256, Version, FunctionArn } = await lambdaClient 60 | .updateFunctionCode({ 61 | FunctionName: lambdaFunction, 62 | ZipFile: newLambdaZip.toBuffer(), 63 | Publish: true, 64 | }) 65 | .promise(); 66 | console.log({ CodeSha256, Version, FunctionArn }); 67 | let attempts = 0; 68 | while (++attempts <= 30) { 69 | const { State } = await lambdaClient 70 | .getFunctionConfiguration({ 71 | FunctionName: FunctionArn!, 72 | }) 73 | .promise(); 74 | if (!State || State === "Pending") { 75 | console.log( 76 | `Waiting for updated Lambda function to become Active, is: ${State} (attempts: ${attempts})` 77 | ); 78 | await new Promise((resolve) => 79 | setTimeout(resolve, Math.min(5000, 1000 * attempts)) 80 | ); 81 | continue; 82 | } 83 | if (State === "Active") { 84 | console.log("Function is now Active!"); 85 | break; 86 | } 87 | throw new Error(`Lambda function state is: ${State}`); 88 | } 89 | return { 90 | physicalResourceId: lambdaFunction, 91 | Data: { CodeSha256, Version, FunctionArn }, 92 | }; 93 | } 94 | 95 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 96 | const { ResourceProperties, RequestType } = event; 97 | 98 | const { PhysicalResourceId } = event as 99 | | CloudFormationCustomResourceDeleteEvent 100 | | CloudFormationCustomResourceUpdateEvent; 101 | 102 | const { LambdaFunction, Configuration } = ResourceProperties; 103 | 104 | let status = Status.SUCCESS; 105 | let physicalResourceId: string | undefined; 106 | let data: { [key: string]: any } | undefined; 107 | let reason: string | undefined; 108 | try { 109 | ({ physicalResourceId, Data: data } = await updateLambdaCode( 110 | RequestType, 111 | LambdaFunction, 112 | Configuration, 113 | PhysicalResourceId 114 | )); 115 | } catch (err) { 116 | console.error(err); 117 | status = Status.FAILED; 118 | reason = `${err}`; 119 | } 120 | await sendCfnResponse({ 121 | event, 122 | status, 123 | data, 124 | physicalResourceId, 125 | reason, 126 | }); 127 | }; 128 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-code-update", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "lambda-code-update", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/lambda-code-update/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-code-update", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !react-app/**/* 3 | !bundle.* 4 | !*.bundle.* 5 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { execSync } from "child_process"; 5 | import { 6 | CloudFormationCustomResourceHandler, 7 | CloudFormationCustomResourceDeleteEvent, 8 | CloudFormationCustomResourceUpdateEvent, 9 | } from "aws-lambda"; 10 | import s3SpaUpload from "s3-spa-upload"; 11 | import { existsSync, mkdirSync, writeFileSync } from "fs"; 12 | import { ncp } from "ncp"; 13 | import { sendCfnResponse, Status } from "./cfn-response"; 14 | 15 | interface Configuration { 16 | BucketName: string; 17 | ClientId: string; 18 | CognitoAuthDomain: string; 19 | RedirectPathSignIn: string; 20 | RedirectPathSignOut: string; 21 | UserPoolArn: string; 22 | OAuthScopes: string; 23 | SignOutUrl: string; 24 | CookieSettings: string; 25 | } 26 | 27 | async function buildSpa(config: Configuration) { 28 | const temp_dir = "/tmp/spa"; 29 | const home_dir = "/tmp/home"; 30 | 31 | console.log( 32 | `Copying SPA sources to ${temp_dir} and making dependencies available there ...` 33 | ); 34 | 35 | [temp_dir, home_dir].forEach((dir) => { 36 | if (!existsSync(dir)) { 37 | mkdirSync(dir); 38 | } 39 | }); 40 | 41 | await Promise.all( 42 | ["src", "public", "package.json", "package-lock.json"].map( 43 | async (path) => 44 | new Promise((resolve, reject) => { 45 | ncp(`${__dirname}/react-app/${path}`, `${temp_dir}/${path}`, (err) => 46 | err ? reject(err) : resolve() 47 | ); 48 | }) 49 | ) 50 | ); 51 | 52 | const userPoolId = config.UserPoolArn.split("/")[1]; 53 | const userPoolRegion = config.UserPoolArn.split(":")[3]; 54 | const cookieSettings = JSON.parse(config.CookieSettings).idToken as 55 | | string 56 | | null; 57 | let cookieDomain = cookieSettings 58 | ?.split(";") 59 | .map((part) => { 60 | const match = part.match(/domain(\s*)=(\s*)(?.+)/i); 61 | return match?.groups?.domain; 62 | }) 63 | .find((domain) => !!domain); 64 | if (!cookieDomain) { 65 | // Cookies without a domain, are called host-only cookies, and are perfectly normal. 66 | // However, AmplifyJS requires to be passed a value for domain, when using cookie storage. 67 | // We'll use " " as a trick to satisfy this check by AmplifyJS, and support host-only cookies. 68 | // 69 | // Note that you do not want to add an exact domain name to a cookie, if you want to have a host-only cookie, 70 | // because a cookie that's explicitly set for e.g. example.com is also readable by subdomain.example.com. 71 | // (In a cookie domain, example.com is treated the same as .example.com) 72 | // The ONLY way to get a host-only cookie, is by NOT including the domain attribute for the cookie at all. 73 | // 74 | // Note that if the cookie storage used in Amplify specifies a domain, this must match 1:1 the domain that 75 | // is used for the cookie by Auth@Edge, otherwise Amplify will have trouble setting that cookie 76 | // (and then e.g. signing out via Amplify no longer works, as that sets the cookies to expire them) 77 | cookieDomain = " "; 78 | } 79 | const reactEnv = `SKIP_PREFLIGHT_CHECK=true 80 | REACT_APP_USER_POOL_ID=${userPoolId} 81 | REACT_APP_USER_POOL_REGION=${userPoolRegion} 82 | REACT_APP_USER_POOL_WEB_CLIENT_ID=${config.ClientId} 83 | REACT_APP_USER_POOL_AUTH_DOMAIN=${config.CognitoAuthDomain} 84 | REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_IN=${config.RedirectPathSignIn} 85 | REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_OUT=${config.RedirectPathSignOut} 86 | REACT_APP_SIGN_OUT_URL=${config.SignOutUrl} 87 | REACT_APP_USER_POOL_SCOPES=${config.OAuthScopes} 88 | REACT_APP_COOKIE_DOMAIN="${cookieDomain}" 89 | INLINE_RUNTIME_CHUNK=false 90 | `; 91 | console.log("React env:\n", reactEnv); 92 | 93 | console.log(`Creating React environment file ${temp_dir}/.env ...`); 94 | writeFileSync(`${temp_dir}/.env`, reactEnv); 95 | 96 | console.log("NPM version:"); 97 | execSync("npm -v", { 98 | cwd: temp_dir, 99 | stdio: "inherit", 100 | env: { ...process.env, HOME: home_dir }, 101 | }); 102 | console.log(`Installing dependencies to build React app in ${temp_dir} ...`); 103 | // Force use of NPM v8 to escape from https://github.com/npm/cli/issues/4783 104 | execSync("npx -p npm@8 npm ci", { 105 | cwd: temp_dir, 106 | stdio: "inherit", 107 | env: { ...process.env, HOME: home_dir }, 108 | }); 109 | console.log(`Running build of React app in ${temp_dir} ...`); 110 | // Force use of NPM v8 to escape from https://github.com/npm/cli/issues/4783 111 | execSync("npx -p npm@8 npm run build", { 112 | cwd: temp_dir, 113 | stdio: "inherit", 114 | env: { ...process.env, HOME: home_dir }, 115 | }); 116 | console.log("Build succeeded"); 117 | 118 | return `${temp_dir}/build`; 119 | } 120 | 121 | async function buildUploadSpa( 122 | action: "Create" | "Update" | "Delete", 123 | config: Configuration, 124 | physicalResourceId?: string 125 | ) { 126 | if (action === "Create" || action === "Update") { 127 | const buildDir = await buildSpa(config); 128 | await s3SpaUpload(buildDir, config.BucketName); 129 | } else { 130 | // "Trick" to empty the bucket is to upload an empty dir 131 | mkdirSync("/tmp/empty_directory", { recursive: true }); 132 | await s3SpaUpload("/tmp/empty_directory", config.BucketName, { 133 | delete: true, 134 | }); 135 | } 136 | return physicalResourceId || "ReactApp"; 137 | } 138 | 139 | export const handler: CloudFormationCustomResourceHandler = async ( 140 | event, 141 | context 142 | ) => { 143 | console.log(JSON.stringify(event, undefined, 4)); 144 | 145 | const { ResourceProperties, RequestType } = event; 146 | 147 | const { ServiceToken, ...config } = ResourceProperties; 148 | 149 | const { PhysicalResourceId } = event as 150 | | CloudFormationCustomResourceDeleteEvent 151 | | CloudFormationCustomResourceUpdateEvent; 152 | 153 | let status = Status.SUCCESS; 154 | let physicalResourceId: string | undefined; 155 | let data: { [key: string]: any } | undefined; 156 | let reason: string | undefined; 157 | try { 158 | physicalResourceId = await Promise.race([ 159 | buildUploadSpa(RequestType, config as Configuration, PhysicalResourceId), 160 | new Promise((_, reject) => 161 | setTimeout( 162 | () => reject(new Error("Task timeout")), 163 | context.getRemainingTimeInMillis() - 500 164 | ) 165 | ), 166 | ]); 167 | } catch (err) { 168 | console.error(err); 169 | status = Status.FAILED; 170 | reason = `${err}`; 171 | } 172 | await sendCfnResponse({ 173 | event, 174 | status, 175 | data, 176 | physicalResourceId, 177 | reason, 178 | }); 179 | }; 180 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "react-app", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # React sources are plain JS (not TS) 26 | !*.js 27 | bundle.js -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `npm run build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@aws-amplify/auth": "^6.6.1", 7 | "@aws-amplify/core": "^6.5.2", 8 | "react": "^17.0.2", 9 | "react-dom": "^17.0.2" 10 | }, 11 | "scripts": { 12 | "start": "react-scripts start", 13 | "build": "react-scripts build", 14 | "test": "react-scripts test", 15 | "eject": "react-scripts eject" 16 | }, 17 | "eslintConfig": { 18 | "extends": "react-app" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "react-scripts": "^5.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/public/aws_sam_introduction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/cloudfront-authorization-at-edge/caafe01b242577a77b9b21a63266e9b31ccb7630/src/cfn-custom-resources/react-app/react-app/public/aws_sam_introduction.png -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/cloudfront-authorization-at-edge/caafe01b242577a77b9b21a63266e9b31ccb7630/src/cfn-custom-resources/react-app/react-app/public/favicon.ico -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Protected Single Page App 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/src/App.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | SPDX-License-Identifier: MIT-0 */ 3 | 4 | .App { 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | } 10 | 11 | .explanation-points { 12 | max-width: 800px; 13 | } 14 | 15 | .explanation { 16 | max-width: 1000px; 17 | text-align: center; 18 | } 19 | 20 | .explanation-tight { 21 | max-width: 500px; 22 | text-align: center; 23 | } 24 | 25 | /* Tooltip container */ 26 | .config { 27 | position: relative; 28 | display: inline-block; 29 | border-bottom: 1px solid black; 30 | } 31 | 32 | /* Tooltip text */ 33 | .config .config-text { 34 | visibility: hidden; 35 | width: 700px; 36 | background-color: #555; 37 | color: #fff; 38 | text-align: left; 39 | padding: 15px; 40 | border-radius: 6px; 41 | 42 | /* Position the tooltip text */ 43 | position: absolute; 44 | z-index: 1; 45 | bottom: 125%; 46 | /* left: 50%; */ 47 | margin-left: -560px; 48 | 49 | /* Fade in tooltip */ 50 | opacity: 0; 51 | transition: opacity 0.3s; 52 | } 53 | 54 | /* Tooltip arrow */ 55 | .config .config-text::after { 56 | content: ""; 57 | position: absolute; 58 | top: 100%; 59 | left: 80%; 60 | margin-left: -5px; 61 | border-width: 5px; 62 | border-style: solid; 63 | border-color: #555 transparent transparent transparent; 64 | } 65 | 66 | /* Show the tooltip text when you mouse over the tooltip container */ 67 | .config:hover .config-text { 68 | visibility: visible; 69 | opacity: 1; 70 | } 71 | 72 | .config-text { 73 | display: block; 74 | font-family: monospace; 75 | white-space: pre; 76 | } 77 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/src/App.js: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, useEffect } from "react"; 5 | import { Amplify } from "@aws-amplify/core"; 6 | import { Auth } from "@aws-amplify/auth"; 7 | import "./App.css"; 8 | 9 | Amplify.configure({ 10 | Auth: { 11 | region: process.env.REACT_APP_USER_POOL_REGION, 12 | userPoolId: process.env.REACT_APP_USER_POOL_ID, 13 | userPoolWebClientId: process.env.REACT_APP_USER_POOL_WEB_CLIENT_ID, 14 | cookieStorage: { 15 | domain: process.env.REACT_APP_COOKIE_DOMAIN, // Use a single space " " for host-only cookies 16 | expires: null, // null means session cookies 17 | path: "/", 18 | secure: true, // for developing on localhost over http: set to false 19 | sameSite: "lax", 20 | }, 21 | oauth: { 22 | domain: process.env.REACT_APP_USER_POOL_AUTH_DOMAIN, 23 | scope: process.env.REACT_APP_USER_POOL_SCOPES.split(","), 24 | redirectSignIn: `https://${window.location.hostname}${process.env.REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_IN}`, 25 | redirectSignOut: `https://${window.location.hostname}${process.env.REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_OUT}`, 26 | responseType: "code", 27 | }, 28 | }, 29 | }); 30 | 31 | const App = () => { 32 | const [state, setState] = useState({ 33 | email: undefined, 34 | username: undefined, 35 | authenticated: undefined, 36 | }); 37 | 38 | useEffect(() => { 39 | Auth.currentSession() 40 | .then((user) => 41 | setState({ 42 | username: user.username, 43 | email: user.getIdToken().decodePayload().email, 44 | authenticated: true, 45 | }) 46 | ) 47 | .catch(() => setState({ authenticated: false })); 48 | 49 | // Schedule check and refresh (when needed) of JWT's every 5 min: 50 | const i = setInterval(() => Auth.currentSession(), 5 * 60 * 1000); 51 | return () => clearInterval(i); 52 | }, []); 53 | 54 | if (state.authenticated === undefined) { 55 | return ( 56 |
57 |

One moment please ...

58 |
59 | ); 60 | } 61 | 62 | if (state.authenticated === false) { 63 | return ( 64 |
65 |

Signed out

66 |

You're signed out.

67 |

68 | You're able to view this page, because it is in your browser's local 69 | cache––you didn't actually download it from CloudFront just now. 70 | Authorization@Edge wouldn't allow that. 71 |

72 |

73 | If you force your browser to reload the page, you'll trigger 74 | Authorization@Edge again, redirecting you to the login page:  75 | 78 |

79 |

80 | If you never want to cache content, set the right cache headers on the 81 | objects in S3; those headers will be respected by CloudFront and web 82 | browsers: 83 |

Cache-Control: no-cache
84 | At the expense of some performance for end-users of course. 85 |

86 |
87 | ); 88 | } 89 | 90 | return ( 91 |
92 |

Private

93 | 94 |

95 | Welcome {state.email || state.username}. You are signed 96 | in! 97 |

98 | 99 |

100 | If you are able to come here, it means everything was deployed in order. 101 | Amongst other things, you've deployed a CloudFront distribution that 102 | you're viewing right now. 103 |

104 | 105 |

What just happened:

106 | 107 |
    108 |
  1. 109 | You just signed-in at the Cognito Hosted UI. You were redirected there 110 | by a Lambda@Edge function; it detected you had not yet authenticated. 111 |
  2. 112 |
  3. 113 | After sign-in you were redirected back by Cognito to your Cloudfront 114 | distribution. Another Lambda@Edge function handled that redirect and 115 | traded the authorization code for JWT's and stored these in your 116 | cookies. 117 |
  4. 118 |
  5. 119 | After that, the Lambda@Edge redirected you back to the URL you 120 | originally requested. This time you have valid JWT's in your cookies 121 | so you were allowed access, and here you are. 122 |
  6. 123 |
124 | 125 |

Good job!

126 | 127 |

128 | The page you're viewing right now is served from S3 (through 129 | CloudFront). You can upload your own SPA (React, Angular, Vue, ...) to 130 | the S3 bucket instead and thus instantly secure it with Cognito 131 | authentication. If your SPA uses AWS Amplify framework with cookie 132 | storage for Auth, the detection of the sign-in status in the SPA will 133 | work seamlessly, because the Lambda@Edge setup uses the same cookies. Of 134 | course your SPA needs to be made aware of the right  135 | 136 | config 137 | 138 | {`Amplify.configure({ 139 | Auth: { 140 | region: "us-east-1", 141 | userPoolId: "${process.env.REACT_APP_USER_POOL_ID}", 142 | userPoolWebClientId: "${process.env.REACT_APP_USER_POOL_WEB_CLIENT_ID}", 143 | cookieStorage: { 144 | domain: "${ 145 | process.env.REACT_APP_COOKIE_DOMAIN 146 | }", // Use a single space " " for host-only cookies 147 | expires: null, // null means session cookies 148 | path: "/", 149 | secure: true, // for developing on localhost over http: set to false 150 | sameSite: "lax" 151 | }, 152 | oauth: { 153 | domain: "${process.env.REACT_APP_USER_POOL_AUTH_DOMAIN}", 154 | scope: ${JSON.stringify( 155 | process.env.REACT_APP_USER_POOL_SCOPES.split(",") 156 | )}, 157 | redirectSignIn: "https://${window.location.hostname}${ 158 | process.env.REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_IN 159 | }", 160 | redirectSignOut: "https://${window.location.hostname}${ 161 | process.env.REACT_APP_USER_POOL_REDIRECT_PATH_SIGN_OUT 162 | }", 163 | responseType: "code" 164 | } 165 | } 166 | });`} 167 | 168 | 169 | . 170 |

171 | 172 |

173 | Take a look at your cookies (open the developer panel in your browser) 174 | and you'll see a couple of JWT's there. Try clearing these cookies and 175 | reload the page, then you'll have to sign in again––unless you are still 176 | signed in at the Cognito hosted UI, in which case you would be 177 | redirected back here seamlessly with new JWT's. 178 |

179 | 180 |

181 | To sign-out both locally (by clearing cookies) as well as at the Cognito 182 | hosted UI, use the sign-out button:{" "} 183 | . That uses 184 | Amplify to sign out. Alternatively, sign out using Lambda@Edge by 185 | explicitly visiting the sign-out URL:{" "} 186 | Sign Out. 187 |

188 | 189 |

190 | Now that you're signed in, you can access any file in the protected S3 191 | bucket, directly through the URL. For example, open this AWS SAM 192 | introduction image:{" "} 193 | 194 | link 195 | 196 | . If you open the link, your browser will automatically send the cookies 197 | along, allowing Cloudfront Lambda@Edge to inspect and validate them, and 198 | only return you that image if the JWT's in your cookies are indeed still 199 | valid. Try clearing your cookies again and then open the link, 200 | Lambda@Edge will then redirect you to the Cognito hosted UI. After 201 | sign-in there (you may still be signed in there) you will be redirected 202 | back to the link location. 203 |

204 |
205 | ); 206 | }; 207 | 208 | export default App; 209 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/src/index.css: -------------------------------------------------------------------------------- 1 | /* Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | SPDX-License-Identifier: MIT-0 */ 3 | 4 | body { 5 | margin: 0; 6 | padding: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 8 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/react-app/react-app/src/index.js: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from "react"; 5 | import ReactDOM from "react-dom"; 6 | import "./index.css"; 7 | import App from "./App"; 8 | 9 | ReactDOM.render(, document.getElementById("root")); 10 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !pages/**/* 3 | !bundle.* 4 | !*.bundle.* 5 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { 5 | CloudFormationCustomResourceHandler, 6 | CloudFormationCustomResourceDeleteEvent, 7 | CloudFormationCustomResourceUpdateEvent, 8 | } from "aws-lambda"; 9 | import staticSiteUpload from "s3-spa-upload"; 10 | import { mkdirSync } from "fs"; 11 | import { sendCfnResponse, Status } from "./cfn-response"; 12 | 13 | interface Configuration { 14 | BucketName: string; 15 | } 16 | 17 | async function uploadPages( 18 | action: "Create" | "Update" | "Delete", 19 | config: Configuration, 20 | physicalResourceId?: string 21 | ) { 22 | if (action === "Create" || action === "Update") { 23 | await staticSiteUpload(`${__dirname}/pages`, config.BucketName); 24 | } else { 25 | // "Trick" to empty the bucket is to upload an empty dir 26 | mkdirSync("/tmp/empty_directory", { recursive: true }); 27 | await staticSiteUpload("/tmp/empty_directory", config.BucketName, { 28 | delete: true, 29 | }); 30 | } 31 | return physicalResourceId || "StaticSite"; 32 | } 33 | 34 | export const handler: CloudFormationCustomResourceHandler = async ( 35 | event, 36 | context 37 | ) => { 38 | console.log(JSON.stringify(event, undefined, 4)); 39 | 40 | const { ResourceProperties, RequestType } = event; 41 | 42 | const { ServiceToken, ...config } = ResourceProperties; 43 | 44 | const { PhysicalResourceId } = event as 45 | | CloudFormationCustomResourceDeleteEvent 46 | | CloudFormationCustomResourceUpdateEvent; 47 | 48 | let status = Status.SUCCESS; 49 | let physicalResourceId: string | undefined; 50 | let data: { [key: string]: any } | undefined; 51 | let reason: string | undefined; 52 | try { 53 | physicalResourceId = await Promise.race([ 54 | uploadPages(RequestType, config as Configuration, PhysicalResourceId), 55 | new Promise((_, reject) => 56 | setTimeout( 57 | () => reject(new Error("Task timeout")), 58 | context.getRemainingTimeInMillis() - 500 59 | ) 60 | ), 61 | ]); 62 | } catch (err) { 63 | console.error(err); 64 | status = Status.FAILED; 65 | reason = `${err}`; 66 | } 67 | await sendCfnResponse({ 68 | event, 69 | status, 70 | data, 71 | physicalResourceId, 72 | reason, 73 | }); 74 | }; 75 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static-site", 3 | "version": "0.1.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "static-site", 9 | "version": "0.1.0", 10 | "devDependencies": {} 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static-site", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": {} 6 | } 7 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/pages/index.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 |

Welcome, please replace me with your own files!

11 | 12 | 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/static-site/pages/styles.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | body { 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | min-height: 100%; 10 | margin: 0; 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/https.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | import { Writable, pipeline } from "stream"; 6 | 7 | export async function fetch(uri: string) { 8 | return new Promise((resolve, reject) => { 9 | const req = request(uri, (res) => 10 | pipeline([res, collectBuffer(resolve)], done) 11 | ); 12 | 13 | function done(error?: Error | null) { 14 | if (!error) return; 15 | req.destroy(error); 16 | reject(error); 17 | } 18 | 19 | req.on("error", done); 20 | 21 | req.end(); 22 | }); 23 | } 24 | 25 | const collectBuffer = (callback: (collectedBuffer: Buffer) => void) => { 26 | const chunks = [] as Buffer[]; 27 | return new Writable({ 28 | write: (chunk, _encoding, done) => { 29 | try { 30 | chunks.push(chunk); 31 | done(); 32 | } catch (err) { 33 | done(err as Error); 34 | } 35 | }, 36 | final: (done) => { 37 | try { 38 | callback(Buffer.concat(chunks)); 39 | done(); 40 | } catch (err) { 41 | done(err as Error); 42 | } 43 | }, 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | 5 | This is a CloudFormation custom resource. It's purpose is to copy the Lambda@Edge functions to us-east-1 6 | as that a requirement from CloudFront. 7 | 8 | To this end, in us-east-1 a separate stack will be created with just these Lambda@Edge functions. 9 | */ 10 | 11 | import { 12 | CloudFormationCustomResourceHandler, 13 | CloudFormationCustomResourceDeleteEvent, 14 | CloudFormationCustomResourceUpdateEvent, 15 | } from "aws-lambda"; 16 | import CloudFormation from "aws-sdk/clients/cloudformation"; 17 | import S3 from "aws-sdk/clients/s3"; 18 | import Lambda from "aws-sdk/clients/lambda"; 19 | import { sendCfnResponse, Status } from "./cfn-response"; 20 | import { fetch } from "./https"; 21 | 22 | const CFN_CLIENT = new CloudFormation(); 23 | const CFN_CLIENT_US_EAST_1 = new CloudFormation({ region: "us-east-1" }); 24 | const LAMBDA_CLIENT = new Lambda(); 25 | const S3_CLIENT_US_EAST_1 = new S3({ region: "us-east-1" }); 26 | 27 | interface CfnTemplateBase { 28 | Resources: { 29 | [key: string]: { 30 | Type: string; 31 | Condition?: string; 32 | Properties?: { 33 | [key: string]: any; 34 | }; 35 | }; 36 | }; 37 | Outputs: { 38 | [key: string]: { 39 | Value: { 40 | "Fn::GetAtt"?: string[]; 41 | Ref?: string; 42 | }; 43 | Export?: { 44 | Name: { 45 | "Fn::Sub": string; 46 | }; 47 | }; 48 | }; 49 | }; 50 | } 51 | 52 | interface CfnTemplateWithLambdas extends CfnTemplateBase { 53 | Resources: { 54 | CheckAuthHandler: CfnLambdaResource; 55 | ParseAuthHandler: CfnLambdaResource; 56 | RefreshAuthHandler: CfnLambdaResource; 57 | HttpHeadersHandler: CfnLambdaResource; 58 | SignOutHandler: CfnLambdaResource; 59 | TrailingSlashHandler?: CfnLambdaResource; 60 | }; 61 | } 62 | 63 | interface CfnLambdaResource { 64 | Type: string; 65 | Condition?: string; 66 | Properties: { 67 | Code: { 68 | S3Bucket: string; 69 | S3Key: string; 70 | }; 71 | Role: string; 72 | [key: string]: any; 73 | }; 74 | } 75 | 76 | const US_EAST_1_STACK_BASE_TEMPLATE = JSON.stringify({ 77 | Description: [ 78 | "Protect downloads of your content hosted on CloudFront with Cognito authentication using Lambda@Edge.", 79 | `This is a peripheral stack to the main stack (with the same name) in region ${CFN_CLIENT.config.region}.`, 80 | "This stack contains the Lambda@Edge functions, these must be deployed to us-east-1", 81 | ].join(" "), 82 | Resources: { 83 | AuthEdgeDeploymentBucket: { 84 | Type: "AWS::S3::Bucket", 85 | }, 86 | }, 87 | Outputs: { 88 | DeploymentBucket: { 89 | Value: { 90 | Ref: "AuthEdgeDeploymentBucket", 91 | }, 92 | }, 93 | }, 94 | } as CfnTemplateBase); 95 | 96 | const LAMBDA_NAMES = [ 97 | "CheckAuthHandler", 98 | "ParseAuthHandler", 99 | "RefreshAuthHandler", 100 | "HttpHeadersHandler", 101 | "SignOutHandler", 102 | "TrailingSlashHandler", 103 | ] as const; 104 | 105 | async function ensureUsEast1LambdaStack(props: { 106 | stackId: string; 107 | stackName: string; 108 | checkAuthHandlerArn: string; 109 | parseAuthHandlerArn: string; 110 | refreshAuthHandlerArn: string; 111 | httpHeadersHandlerArn: string; 112 | signOutHandlerArn: string; 113 | trailingSlashHandlerArn?: string; 114 | lambdaRoleArn: string; 115 | requestType: "Create" | "Update" | "Delete"; 116 | physicalResourceId: string | undefined; 117 | }) { 118 | // This function will create/update a stack in us-east-1, with the Lambda@Edge functions 119 | // (or clean up after itself upon deleting) 120 | 121 | // If we're deleting, delete the us-east-1 stack, if it still exists 122 | if (props.requestType === "Delete") { 123 | console.log("Getting status of us-east-1 stack ..."); 124 | const { Stacks: stacks } = await CFN_CLIENT_US_EAST_1.describeStacks({ 125 | StackName: props.stackName, 126 | }) 127 | .promise() 128 | .catch(() => ({ Stacks: undefined })); 129 | if (stacks?.length) { 130 | console.log("Deleting us-east-1 stack ..."); 131 | const deploymentBucket = stacks[0].Outputs?.find( 132 | (output) => output.OutputKey === "DeploymentBucket" 133 | )?.OutputValue; 134 | if (deploymentBucket) { 135 | await emptyBucket({ bucket: deploymentBucket }); 136 | } 137 | await CFN_CLIENT_US_EAST_1.deleteStack({ 138 | StackName: props.stackName, 139 | }).promise(); 140 | console.log("us-east-1 stack deleted"); 141 | } else { 142 | console.log("us-east-1 stack already deleted"); 143 | } 144 | return; 145 | } 146 | 147 | // To be able to create the Lambda@Edge functions in us-east-1 we first need to create 148 | // an S3 bucket there, to hold the code. 149 | const deploymentBucket = await ensureDeploymentUsEast1Stack(props); 150 | 151 | // To get the Lambda@Edge configuration, we'll simply download the CloudFormation template for 152 | // this, the current, stack, and use the configuration that is in there. 153 | console.log("Getting CFN template ..."); 154 | const { TemplateBody: originalTemplate } = await CFN_CLIENT.getTemplate({ 155 | StackName: props.stackId, 156 | TemplateStage: "Processed", 157 | }).promise(); 158 | if (!originalTemplate) 159 | throw new Error( 160 | `Failed to get template for stack ${props.stackName} (${props.stackId})` 161 | ); 162 | const parsedOriginalTemplate = JSON.parse( 163 | originalTemplate 164 | ) as CfnTemplateWithLambdas; 165 | const templateForUsEast1 = JSON.parse( 166 | US_EAST_1_STACK_BASE_TEMPLATE 167 | ) as CfnTemplateWithLambdas; 168 | 169 | // For each concerned lambda, extract it's configuration from the downloaded CloudFormation template 170 | // and add it to the new template, for us-east-1 deployment 171 | await Promise.all( 172 | LAMBDA_NAMES.map((lambdaName) => { 173 | const lambdaProperty = Object.entries(props).find( 174 | ([key, lambdaArn]) => 175 | key.toLowerCase().startsWith(lambdaName.toLowerCase()) && !!lambdaArn 176 | ); 177 | const lambdaArn = lambdaProperty && lambdaProperty[1]; 178 | if (!lambdaArn) { 179 | console.log( 180 | `Couldn't locate ARN for lambda ${lambdaName} in input properties: ${JSON.stringify( 181 | props, 182 | null, 183 | 2 184 | )}` 185 | ); 186 | return; 187 | } 188 | // Copy the Lambda code to us-east-1, and set that location in the new CloudFormation template 189 | const lambdaResource = parsedOriginalTemplate.Resources[lambdaName]!; 190 | return copyLambdaCodeToUsEast1({ 191 | lambdaArn, 192 | toBucket: deploymentBucket, 193 | key: lambdaResource.Properties.Code.S3Key, 194 | }).then(() => { 195 | const updatedLambdaResource: CfnLambdaResource = lambdaResource; 196 | updatedLambdaResource.Properties.Code.S3Bucket = deploymentBucket; 197 | delete updatedLambdaResource.Condition; 198 | updatedLambdaResource.Properties.Role = props.lambdaRoleArn; 199 | updatedLambdaResource.Properties.FunctionName = lambdaArn 200 | .split(":") 201 | .pop(); 202 | templateForUsEast1.Resources[lambdaName] = updatedLambdaResource; 203 | templateForUsEast1.Outputs[lambdaName] = { 204 | Value: { 205 | "Fn::GetAtt": [lambdaName, "Arn"], 206 | }, 207 | Export: { 208 | Name: { 209 | "Fn::Sub": "${AWS::StackName}-" + lambdaName, 210 | }, 211 | }, 212 | }; 213 | }); 214 | }) 215 | ); 216 | console.log( 217 | "Constructed CloudFormation template for Lambda's:", 218 | JSON.stringify(templateForUsEast1, null, 2) 219 | ); 220 | 221 | // Deploy the template with the Lambda@Edge functions to us-east-1 222 | return ensureLambdaUsEast1Stack({ 223 | ...props, 224 | newTemplate: JSON.stringify(templateForUsEast1), 225 | }); 226 | } 227 | 228 | async function ensureLambdaUsEast1Stack(props: { 229 | stackId: string; 230 | stackName: string; 231 | newTemplate: string; 232 | }) { 233 | console.log( 234 | "Creating change set for adding lambda functions to us-east-1 stack ..." 235 | ); 236 | const { Id: changeSetArn } = await CFN_CLIENT_US_EAST_1.createChangeSet({ 237 | StackName: props.stackName, 238 | ChangeSetName: props.stackName, 239 | TemplateBody: props.newTemplate, 240 | ChangeSetType: "UPDATE", 241 | ResourceTypes: ["AWS::Lambda::Function"], 242 | }).promise(); 243 | if (!changeSetArn) 244 | throw new Error( 245 | "Failed to create change set for lambda handlers deployment" 246 | ); 247 | console.log( 248 | "Waiting for completion of change set for adding lambda functions to us-east-1 stack ..." 249 | ); 250 | await CFN_CLIENT_US_EAST_1.waitFor("changeSetCreateComplete", { 251 | ChangeSetName: changeSetArn, 252 | }) 253 | .promise() 254 | .catch((err) => 255 | console.log( 256 | `Caught exception while waiting for change set create completion: ${err}` 257 | ) 258 | ); 259 | const { Status: status, StatusReason: reason } = 260 | await CFN_CLIENT_US_EAST_1.describeChangeSet({ 261 | ChangeSetName: changeSetArn, 262 | }).promise(); 263 | if (status === "FAILED") { 264 | // The only reason we'll allow a FAILED change set is if there were no changes 265 | if (!reason?.includes("didn't contain changes")) { 266 | throw new Error(`Failed to create change set: ${reason}`); 267 | } else { 268 | // No changes to make to the Lambda@Edge functions, clean up the change set then 269 | await CFN_CLIENT_US_EAST_1.deleteChangeSet({ 270 | ChangeSetName: changeSetArn, 271 | }).promise(); 272 | 273 | // Need to get the outputs (Lambda ARNs) from the existing stack then 274 | const { Stacks: existingStacks } = 275 | await CFN_CLIENT_US_EAST_1.describeStacks({ 276 | StackName: props.stackName, 277 | }).promise(); 278 | const existingOutputs = extractOutputsFromStackResponse(existingStacks); 279 | console.log( 280 | `us-east-1 stack unchanged. Stack outputs: ${JSON.stringify( 281 | existingOutputs, 282 | null, 283 | 2 284 | )}` 285 | ); 286 | return existingOutputs as { [key: string]: string }; 287 | } 288 | } 289 | 290 | // Execute change set and wait for completion 291 | console.log( 292 | "Executing change set for adding lambda functions to us-east-1 stack ..." 293 | ); 294 | await CFN_CLIENT_US_EAST_1.executeChangeSet({ 295 | ChangeSetName: changeSetArn, 296 | }).promise(); 297 | console.log( 298 | "Waiting for completion of execute change set for adding lambda functions to us-east-1 stack ..." 299 | ); 300 | const { Stacks: updatedStacks } = await CFN_CLIENT_US_EAST_1.waitFor( 301 | "stackUpdateComplete", 302 | { 303 | StackName: props.stackName, 304 | } 305 | ).promise(); 306 | const outputs = extractOutputsFromStackResponse(updatedStacks); 307 | console.log( 308 | `us-east-1 stack succesfully updated. Stack outputs: ${JSON.stringify( 309 | outputs, 310 | null, 311 | 2 312 | )}` 313 | ); 314 | return outputs as { [key: string]: string }; 315 | } 316 | 317 | function extractOutputsFromStackResponse(stacks?: CloudFormation.Stack[]) { 318 | // find the ARNs for all Lambda functions, which will be output from this custom resource 319 | 320 | const outputs = LAMBDA_NAMES.reduce((acc, lambdaName) => { 321 | const lambdaArn = stacks?.[0].Outputs?.find( 322 | (output) => output.OutputKey === lambdaName 323 | )?.OutputValue; 324 | if (lambdaArn) { 325 | return { ...acc, [lambdaName]: lambdaArn }; 326 | } else { 327 | return acc; 328 | } 329 | }, {} as { [key: string]: string | undefined }); 330 | return outputs; 331 | } 332 | 333 | async function ensureDeploymentUsEast1Stack(props: { 334 | stackId: string; 335 | stackName: string; 336 | }) { 337 | // Create a stack in us-east-1 with a deployment bucket 338 | // (in a next step, Lambda fuctions will be added to this stack) 339 | 340 | console.log("Checking if us-east-1 stack already exists ..."); 341 | const { Stacks: usEast1Stacks } = await CFN_CLIENT_US_EAST_1.describeStacks({ 342 | StackName: props.stackName, 343 | }) 344 | .promise() 345 | .catch(() => ({ Stacks: undefined })); 346 | if (usEast1Stacks?.length) { 347 | const deploymentBucket = usEast1Stacks[0].Outputs?.find( 348 | (output) => output.OutputKey === "DeploymentBucket" 349 | )?.OutputValue; 350 | if (!deploymentBucket) 351 | throw new Error("Failed to locate deployment bucket in us-east-1 stack"); 352 | console.log( 353 | `us-east-1 stack exists. Deployment bucket: ${deploymentBucket}` 354 | ); 355 | return deploymentBucket; 356 | } 357 | 358 | // Get the stack tags, we'll add them to the peripheral stack in us-east-1 too 359 | console.log("Getting CFN stack tags ..."); 360 | const { Stacks: mainRegionStacks } = await CFN_CLIENT.describeStacks({ 361 | StackName: props.stackId, 362 | }).promise(); 363 | if (!mainRegionStacks?.length) { 364 | throw new Error( 365 | `Failed to describe stack ${props.stackName} (${props.stackId})` 366 | ); 367 | } 368 | 369 | // Create the stack 370 | console.log("Creating change set for us-east-1 stack ..."); 371 | const { Id: changeSetArn } = await CFN_CLIENT_US_EAST_1.createChangeSet({ 372 | StackName: props.stackName, 373 | ChangeSetName: props.stackName, 374 | TemplateBody: US_EAST_1_STACK_BASE_TEMPLATE, 375 | ChangeSetType: "CREATE", 376 | ResourceTypes: ["AWS::S3::Bucket"], 377 | Tags: mainRegionStacks[0].Tags, 378 | }).promise(); 379 | if (!changeSetArn) 380 | throw new Error("Failed to create change set for bucket deployment"); 381 | console.log("Waiting for change set create complete for us-east-1 stack ..."); 382 | await CFN_CLIENT_US_EAST_1.waitFor("changeSetCreateComplete", { 383 | ChangeSetName: changeSetArn, 384 | }).promise(); 385 | console.log("Executing change set for us-east-1 stack ..."); 386 | await CFN_CLIENT_US_EAST_1.executeChangeSet({ 387 | ChangeSetName: changeSetArn, 388 | }).promise(); 389 | console.log("Waiting for creation of us-east-1 stack ..."); 390 | const { Stacks: createdStacks } = await CFN_CLIENT_US_EAST_1.waitFor( 391 | "stackCreateComplete", 392 | { 393 | StackName: props.stackName, 394 | } 395 | ).promise(); 396 | const deploymentBucket = createdStacks?.[0].Outputs?.find( 397 | (output) => output.OutputKey === "DeploymentBucket" 398 | )?.OutputValue; 399 | if (!deploymentBucket) 400 | throw new Error("Failed to locate deployment bucket in new stack"); 401 | return deploymentBucket; 402 | } 403 | 404 | async function copyLambdaCodeToUsEast1(props: { 405 | lambdaArn: string; 406 | toBucket: string; 407 | key: string; 408 | }) { 409 | console.log(`Copying Lambda code: ${JSON.stringify(props, null, 2)}`); 410 | const { Code } = await LAMBDA_CLIENT.getFunction({ 411 | FunctionName: props.lambdaArn, 412 | }).promise(); 413 | console.log( 414 | `Downloading lambda code for ${props.lambdaArn} from ${Code!.Location!}` 415 | ); 416 | const data = await fetch(Code!.Location!); 417 | await S3_CLIENT_US_EAST_1.putObject({ 418 | Bucket: props.toBucket, 419 | Key: props.key, 420 | Body: data, 421 | }).promise(); 422 | return props; 423 | } 424 | 425 | async function emptyBucket(props: { bucket: string }) { 426 | const params: S3.ListObjectsV2Request = { 427 | Bucket: props.bucket, 428 | }; 429 | do { 430 | console.log(`Listing objects in bucket ${props.bucket} ...`); 431 | const { Contents: s3objects, NextContinuationToken } = 432 | await S3_CLIENT_US_EAST_1.listObjectsV2(params).promise(); 433 | 434 | if (!s3objects?.length) break; 435 | console.log(`Deleting ${s3objects.length} S3 objects ...`); 436 | 437 | const { Errors: errors } = await S3_CLIENT_US_EAST_1.deleteObjects({ 438 | Bucket: props.bucket, 439 | Delete: { 440 | Objects: s3objects.filter((o) => !!o.Key).map((o) => ({ Key: o.Key! })), 441 | }, 442 | }).promise(); 443 | 444 | if (errors?.length) { 445 | console.log("Failed to delete objects:", JSON.stringify(errors)); 446 | } 447 | 448 | params.ContinuationToken = NextContinuationToken; 449 | } while (params.ContinuationToken); 450 | } 451 | 452 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 453 | console.log(JSON.stringify(event, undefined, 4)); 454 | const { StackId: stackId, RequestType: requestType } = event; 455 | const stackName = stackId.split("/")[1]; 456 | 457 | const { 458 | PhysicalResourceId: physicalResourceId, 459 | ResourceProperties: { 460 | LambdaRoleArn: lambdaRoleArn, 461 | CheckAuthHandlerArn: checkAuthHandlerArn, 462 | ParseAuthHandlerArn: parseAuthHandlerArn, 463 | RefreshAuthHandlerArn: refreshAuthHandlerArn, 464 | HttpHeadersHandlerArn: httpHeadersHandlerArn, 465 | SignOutHandlerArn: signOutHandlerArn, 466 | TrailingSlashHandlerArn: trailingSlashHandlerArn, 467 | }, 468 | } = event as 469 | | CloudFormationCustomResourceDeleteEvent 470 | | CloudFormationCustomResourceUpdateEvent; 471 | 472 | let status = Status.SUCCESS; 473 | let data: { [key: string]: any } | undefined; 474 | let reason: string | undefined; 475 | try { 476 | data = await ensureUsEast1LambdaStack({ 477 | stackId, 478 | stackName, 479 | physicalResourceId, 480 | requestType, 481 | lambdaRoleArn, 482 | checkAuthHandlerArn, 483 | parseAuthHandlerArn, 484 | refreshAuthHandlerArn, 485 | httpHeadersHandlerArn, 486 | signOutHandlerArn, 487 | trailingSlashHandlerArn, 488 | }); 489 | } catch (err) { 490 | console.error(err); 491 | status = Status.FAILED; 492 | reason = `${err}`; 493 | } 494 | await sendCfnResponse({ 495 | event, 496 | status, 497 | data, 498 | physicalResourceId: stackName, 499 | reason, 500 | }); 501 | }; 502 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "us-east-1-lambda-stack", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "us-east-1-lambda-stack", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/us-east-1-lambda-stack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "us-east-1-lambda-stack", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-client/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-client/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-client/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | 5 | This is a CloudFormation custom resource. It's purpose is to: 6 | 7 | - Update a User Pool Client's redirect URL's 8 | 9 | We need to do this in a custom resource, to support the scenario of updating a pre-existing User Pool Client 10 | */ 11 | 12 | import { 13 | CloudFormationCustomResourceHandler, 14 | CloudFormationCustomResourceUpdateEvent, 15 | } from "aws-lambda"; 16 | import CognitoIdentityServiceProvider from "aws-sdk/clients/cognitoidentityserviceprovider"; 17 | import { sendCfnResponse, Status } from "./cfn-response"; 18 | 19 | const CUSTOM_RESOURCE_CURRENT_VERSION_NAME = "UpdatedUserPoolClientV2"; 20 | const SENTINEL_DOMAIN = "example.com"; 21 | 22 | async function getUserPoolClient(props: Props) { 23 | const userPoolId = props.UserPoolArn.split("/")[1]; 24 | const userPoolRegion = props.UserPoolArn.split(":")[3]; 25 | const cognitoClient = new CognitoIdentityServiceProvider({ 26 | region: userPoolRegion, 27 | }); 28 | const input = { 29 | ClientId: props.UserPoolClientId, 30 | UserPoolId: userPoolId, 31 | }; 32 | console.debug("Describing User Pool Client", JSON.stringify(input, null, 4)); 33 | const { UserPoolClient } = await cognitoClient 34 | .describeUserPoolClient(input) 35 | .promise(); 36 | if (!UserPoolClient) { 37 | throw new Error("User Pool Client not found!"); 38 | } 39 | return UserPoolClient; 40 | } 41 | 42 | async function updateUserPoolClient( 43 | props: Props, 44 | redirectUrisSignIn: string[], 45 | redirectUrisSignOut: string[], 46 | existingUserPoolClient: CognitoIdentityServiceProvider.UserPoolClientType 47 | ) { 48 | const userPoolId = props.UserPoolArn.split("/")[1]; 49 | const userPoolRegion = props.UserPoolArn.split(":")[3]; 50 | const cognitoClient = new CognitoIdentityServiceProvider({ 51 | region: userPoolRegion, 52 | }); 53 | 54 | const CallbackURLs = [...new Set(redirectUrisSignIn)].filter( 55 | (uri) => new URL(uri).hostname !== SENTINEL_DOMAIN 56 | ); 57 | const LogoutURLs = [...new Set(redirectUrisSignOut)].filter( 58 | (uri) => new URL(uri).hostname !== SENTINEL_DOMAIN 59 | ); 60 | 61 | // To be able to set the redirect URL's, we must enable OAuth––required by Cognito 62 | // Vice versa, when removing redirect URL's, we must disable OAuth if there's no more redirect URL's left 63 | let AllowedOAuthFlows: string[]; 64 | let AllowedOAuthFlowsUserPoolClient: boolean; 65 | let AllowedOAuthScopes: string[]; 66 | if (CallbackURLs.length) { 67 | AllowedOAuthFlows = ["code"]; 68 | AllowedOAuthFlowsUserPoolClient = true; 69 | AllowedOAuthScopes = props.OAuthScopes; 70 | } else { 71 | AllowedOAuthFlows = []; 72 | AllowedOAuthFlowsUserPoolClient = false; 73 | AllowedOAuthScopes = []; 74 | } 75 | 76 | // Provide existing fields as well (excluding properties not valid for Update operations), experience teaches this prevents errors when calling the Cognito API 77 | // https://github.com/aws-samples/cloudfront-authorization-at-edge/issues/144 78 | // https://github.com/aws-samples/cloudfront-authorization-at-edge/issues/172 79 | const existingFields = { ...existingUserPoolClient }; 80 | delete existingFields.CreationDate; 81 | delete existingFields.LastModifiedDate; 82 | delete existingFields.ClientSecret; 83 | 84 | const input: CognitoIdentityServiceProvider.Types.UpdateUserPoolClientRequest = 85 | { 86 | ...existingFields, 87 | AllowedOAuthFlows, 88 | AllowedOAuthFlowsUserPoolClient, 89 | AllowedOAuthScopes, 90 | ClientId: props.UserPoolClientId, 91 | UserPoolId: userPoolId, 92 | CallbackURLs, 93 | LogoutURLs, 94 | }; 95 | console.debug("Updating User Pool Client", JSON.stringify(input, null, 4)); 96 | await cognitoClient.updateUserPoolClient(input).promise(); 97 | } 98 | 99 | async function undoPriorUpdate( 100 | props: Props, 101 | redirectUrisSignInToRemove: string[], 102 | redirectUrisSignOutToRemove: string[] 103 | ) { 104 | // Get existing callback URL's 105 | const existingUserPoolClient = await getUserPoolClient(props); 106 | const existingRedirectUrisSignIn = existingUserPoolClient.CallbackURLs || []; 107 | const existingRedirectUrisSignOut = existingUserPoolClient.LogoutURLs || []; 108 | 109 | // Remove the callback URL's we added to the list earlier 110 | const redirectUrisSignInToKeep = existingRedirectUrisSignIn.filter( 111 | (uri) => !redirectUrisSignInToRemove.includes(uri) 112 | ); 113 | const redirectUrisSignOutToKeep = existingRedirectUrisSignOut.filter( 114 | (uri) => !redirectUrisSignOutToRemove.includes(uri) 115 | ); 116 | 117 | await updateUserPoolClient( 118 | props, 119 | redirectUrisSignInToKeep, 120 | redirectUrisSignOutToKeep, 121 | existingUserPoolClient 122 | ); 123 | } 124 | 125 | async function doNewUpdate( 126 | props: Props, 127 | redirectUrisSignIn: string[], 128 | redirectUrisSignOut: string[] 129 | ) { 130 | // Get existing callback URL's 131 | const existingUserPoolClient = await getUserPoolClient(props); 132 | const existingRedirectUrisSignIn = existingUserPoolClient?.CallbackURLs || []; 133 | const existingRedirectUrisSignOut = existingUserPoolClient?.LogoutURLs || []; 134 | 135 | // Add new callback url's 136 | const redirectUrisSignInToSet = [ 137 | ...existingRedirectUrisSignIn, 138 | ...redirectUrisSignIn, 139 | ]; 140 | const redirectUrisSignOutToSet = [ 141 | ...existingRedirectUrisSignOut, 142 | ...redirectUrisSignOut, 143 | ]; 144 | await updateUserPoolClient( 145 | props, 146 | redirectUrisSignInToSet, 147 | redirectUrisSignOutToSet, 148 | existingUserPoolClient 149 | ); 150 | } 151 | 152 | interface Props { 153 | UserPoolArn: string; 154 | UserPoolClientId: string; 155 | OAuthScopes: string[]; 156 | CloudFrontDistributionDomainName: string; 157 | RedirectPathSignIn: string; 158 | RedirectPathSignOut: string; 159 | AlternateDomainNames: string[]; 160 | } 161 | 162 | function getRedirectUris(props: Props) { 163 | const redirectDomains = [ 164 | props.CloudFrontDistributionDomainName, 165 | ...props.AlternateDomainNames, 166 | ].filter((domain) => !!domain); 167 | const redirectUrisSignIn = redirectDomains.map( 168 | (domain) => `https://${domain}${props.RedirectPathSignIn}` 169 | ); 170 | const redirectUrisSignOut = redirectDomains.map( 171 | (domain) => `https://${domain}${props.RedirectPathSignOut}` 172 | ); 173 | return { redirectUrisSignIn, redirectUrisSignOut }; 174 | } 175 | 176 | async function updateCognitoUserPoolClient( 177 | requestType: "Create" | "Update" | "Delete", 178 | currentProps: Props, 179 | oldProps?: Props, 180 | physicalResourceId?: string 181 | ) { 182 | const currentUris = getRedirectUris(currentProps); 183 | if (requestType === "Create") { 184 | await doNewUpdate( 185 | currentProps, 186 | currentUris.redirectUrisSignIn, 187 | currentUris.redirectUrisSignOut 188 | ); 189 | } else if (requestType === "Update") { 190 | if (physicalResourceId === CUSTOM_RESOURCE_CURRENT_VERSION_NAME) { 191 | const priorUris = getRedirectUris(oldProps!); 192 | await undoPriorUpdate( 193 | oldProps!, 194 | priorUris.redirectUrisSignIn, 195 | priorUris.redirectUrisSignOut 196 | ); 197 | } 198 | await doNewUpdate( 199 | currentProps, 200 | currentUris.redirectUrisSignIn, 201 | currentUris.redirectUrisSignOut 202 | ); 203 | } else if (requestType === "Delete") { 204 | if (physicalResourceId === CUSTOM_RESOURCE_CURRENT_VERSION_NAME) { 205 | await undoPriorUpdate( 206 | currentProps, 207 | currentUris.redirectUrisSignIn, 208 | currentUris.redirectUrisSignOut 209 | ); 210 | } 211 | } 212 | 213 | return { 214 | RedirectUrisSignIn: currentUris.redirectUrisSignIn.join(","), 215 | RedirectUrisSignOut: currentUris.redirectUrisSignOut.join(","), 216 | }; 217 | } 218 | 219 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 220 | console.log(JSON.stringify(event, undefined, 4)); 221 | const { ResourceProperties, RequestType } = event; 222 | const { OldResourceProperties } = 223 | event as CloudFormationCustomResourceUpdateEvent; 224 | 225 | let physicalResourceId: string; 226 | if (event.RequestType === "Create") { 227 | physicalResourceId = CUSTOM_RESOURCE_CURRENT_VERSION_NAME; 228 | } else { 229 | physicalResourceId = event.PhysicalResourceId; 230 | } 231 | 232 | let status = Status.SUCCESS; 233 | let data: { [key: string]: any } | undefined; 234 | let reason: string | undefined; 235 | try { 236 | data = await updateCognitoUserPoolClient( 237 | RequestType, 238 | ResourceProperties as unknown as Props, 239 | OldResourceProperties as unknown as Props, 240 | physicalResourceId 241 | ); 242 | } catch (err) { 243 | console.error(err); 244 | status = Status.FAILED; 245 | reason = `${err}`; 246 | } 247 | await sendCfnResponse({ 248 | event, 249 | status, 250 | data, 251 | physicalResourceId, 252 | reason, 253 | }); 254 | }; 255 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-client/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-pool-client", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "user-pool-client", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-pool-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-domain/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-domain/cfn-response.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { request } from "https"; 5 | 6 | export enum Status { 7 | "SUCCESS" = "SUCCESS", 8 | "FAILED" = "FAILED", 9 | } 10 | 11 | export async function sendCfnResponse(props: { 12 | event: { 13 | StackId: string; 14 | RequestId: string; 15 | LogicalResourceId: string; 16 | ResponseURL: string; 17 | }; 18 | status: Status; 19 | reason?: string; 20 | data?: { 21 | [key: string]: string; 22 | }; 23 | physicalResourceId?: string; 24 | }) { 25 | const response = { 26 | Status: props.status, 27 | Reason: props.reason?.toString() || "See CloudWatch logs", 28 | PhysicalResourceId: props.physicalResourceId || "no-explicit-id", 29 | StackId: props.event.StackId, 30 | RequestId: props.event.RequestId, 31 | LogicalResourceId: props.event.LogicalResourceId, 32 | Data: props.data || {}, 33 | }; 34 | 35 | await new Promise((resolve, reject) => { 36 | const options = { 37 | method: "PUT", 38 | headers: { "content-type": "" }, 39 | }; 40 | request(props.event.ResponseURL, options) 41 | .on("error", (err) => { 42 | reject(err); 43 | }) 44 | .end(JSON.stringify(response), "utf8", resolve); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-domain/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | SPDX-License-Identifier: MIT-0 4 | 5 | This is a CloudFormation custom resource. It's purpose is to: 6 | 7 | - Lookup the URL of an existing User Pool Domain 8 | 9 | We need to do this in a custom resource to support the scenario of looking up a pre-existing User Pool Domain 10 | */ 11 | 12 | import { 13 | CloudFormationCustomResourceHandler, 14 | CloudFormationCustomResourceDeleteEvent, 15 | CloudFormationCustomResourceUpdateEvent, 16 | } from "aws-lambda"; 17 | import CognitoIdentityServiceProvider from "aws-sdk/clients/cognitoidentityserviceprovider"; 18 | import { sendCfnResponse, Status } from "./cfn-response"; 19 | 20 | async function ensureCognitoUserPoolDomain( 21 | action: "Create" | "Update" | "Delete", 22 | newUserPoolArn: string, 23 | physicalResourceId?: string 24 | ) { 25 | if (action === "Delete") { 26 | return physicalResourceId!; 27 | } 28 | const newUserPoolId = newUserPoolArn.split("/")[1]; 29 | const newUserPoolRegion = newUserPoolArn.split(":")[3]; 30 | const cognitoClient = new CognitoIdentityServiceProvider({ 31 | region: newUserPoolRegion, 32 | }); 33 | const { UserPool } = await cognitoClient 34 | .describeUserPool({ UserPoolId: newUserPoolId }) 35 | .promise(); 36 | if (!UserPool) { 37 | throw new Error(`User Pool ${newUserPoolArn} does not exist`); 38 | } 39 | if (UserPool.CustomDomain) { 40 | return UserPool.CustomDomain; 41 | } else if (UserPool.Domain) { 42 | return `${UserPool.Domain}.auth.${newUserPoolRegion}.amazoncognito.com`; 43 | } else { 44 | throw new Error( 45 | `User Pool ${newUserPoolArn} does not have a domain set up yet` 46 | ); 47 | } 48 | } 49 | 50 | export const handler: CloudFormationCustomResourceHandler = async (event) => { 51 | console.log(JSON.stringify(event, undefined, 4)); 52 | const { ResourceProperties, RequestType } = event; 53 | 54 | const { PhysicalResourceId } = event as 55 | | CloudFormationCustomResourceDeleteEvent 56 | | CloudFormationCustomResourceUpdateEvent; 57 | let status = Status.SUCCESS; 58 | let physicalResourceId: string | undefined; 59 | let data: { [key: string]: any } | undefined; 60 | let reason: string | undefined; 61 | try { 62 | physicalResourceId = await ensureCognitoUserPoolDomain( 63 | RequestType, 64 | ResourceProperties.UserPoolArn, 65 | PhysicalResourceId 66 | ); 67 | } catch (err) { 68 | console.error(err); 69 | status = Status.FAILED; 70 | reason = `${err}`; 71 | } 72 | await sendCfnResponse({ 73 | event, 74 | status, 75 | data, 76 | physicalResourceId, 77 | reason, 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-domain/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-pool-domain", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "user-pool-domain", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/cfn-custom-resources/user-pool-domain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "user-pool-domain", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda-edge/check-auth/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/check-auth/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { stringify as stringifyQueryString } from "querystring"; 5 | import { createHash } from "crypto"; 6 | import { CloudFrontRequestHandler } from "aws-lambda"; 7 | import * as common from "../shared/shared"; 8 | 9 | const CONFIG = common.getConfigWithJwtVerifier(); 10 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 11 | 12 | export const handler: CloudFrontRequestHandler = async (event) => { 13 | CONFIG.logger.debug("Event:", event); 14 | const request = event.Records[0].cf.request; 15 | const domainName = request.headers["host"][0].value; 16 | const requestedUri = `${request.uri}${ 17 | request.querystring ? "?" + request.querystring : "" 18 | }`; 19 | let refreshToken: string | undefined = ""; 20 | let cookies: ReturnType = {}; 21 | try { 22 | cookies = common.extractAndParseCookies( 23 | request.headers, 24 | CONFIG.clientId, 25 | CONFIG.cookieCompatibility 26 | ); 27 | CONFIG.logger.debug("Extracted cookies:", cookies); 28 | refreshToken = cookies.refreshToken; 29 | 30 | // If there's no ID token in your cookies, then you are not signed in yet 31 | if (!cookies.idToken) { 32 | throw new Error("No ID token present in cookies"); 33 | } 34 | 35 | // Verify the ID-token (JWT), this throws an error if the JWT is not valid 36 | const payload = await CONFIG.jwtVerifier.verify(cookies.idToken); 37 | CONFIG.logger.debug("JWT payload:", payload); 38 | 39 | // Return the request unaltered to allow access to the resource: 40 | CONFIG.logger.debug("Access allowed:", request); 41 | return request; 42 | } catch (err) { 43 | CONFIG.logger.info("Access denied:", err); 44 | 45 | // If the JWT is expired we can try to refresh it 46 | // We'll only do this if refresh did not fail earlier (detected by a marker cookie) 47 | // Refresh is done by redirecting the user to the refresh path (where it will actually happen) 48 | // If the refresh works, the user will be redirected back here (this time with valid JWTs) 49 | if (err instanceof common.JwtExpiredError && !cookies.refreshFailed) { 50 | CONFIG.logger.debug("Redirecting user to refresh path"); 51 | return redirectToRefreshPath({ domainName, requestedUri }); 52 | } 53 | 54 | // If the user is not in the right Cognito group, (s)he needs to contact an admin 55 | // If legitimate, the admin should add the user to the Cognito group, 56 | // after that the user will need to re-attempt sign-in 57 | if (err instanceof common.CognitoJwtInvalidGroupError) { 58 | CONFIG.logger.debug("User isn't in the right Cognito group"); 59 | return showContactAdminErrorPage({ err, domainName }); 60 | } 61 | 62 | // Send the user to the Cognito Hosted UI to sign-in 63 | CONFIG.logger.debug("Redirecting user to Cognito Hosted UI to sign-in"); 64 | return redirectToCognitoHostedUI({ domainName, requestedUri }); 65 | } 66 | }; 67 | 68 | function redirectToCognitoHostedUI({ 69 | domainName, 70 | requestedUri, 71 | }: { 72 | domainName: string; 73 | requestedUri: string; 74 | }) { 75 | // Generate new state which involves a signed nonce 76 | // This way we can check later whether the sign-in redirect was done by us (it should, to prevent CSRF attacks) 77 | const nonce = generateNonce(); 78 | const state = { 79 | nonce, 80 | nonceHmac: common.sign( 81 | nonce, 82 | CONFIG.nonceSigningSecret, 83 | CONFIG.nonceLength 84 | ), 85 | ...generatePkceVerifier(), 86 | }; 87 | CONFIG.logger.debug("Using new state\n", state); 88 | 89 | const loginQueryString = stringifyQueryString({ 90 | redirect_uri: `https://${domainName}${CONFIG.redirectPathSignIn}`, 91 | response_type: "code", 92 | client_id: CONFIG.clientId, 93 | state: 94 | // Encode the state variable as base64 to avoid a bug in Cognito hosted UI when using multiple identity providers 95 | // Cognito decodes the URL, causing a malformed link due to the JSON string, and results in an empty 400 response from Cognito. 96 | common.urlSafe.stringify( 97 | Buffer.from( 98 | JSON.stringify({ nonce: state.nonce, requestedUri }) 99 | ).toString("base64") 100 | ), 101 | scope: CONFIG.oauthScopes.join(" "), 102 | code_challenge_method: "S256", 103 | code_challenge: state.pkceHash, 104 | }); 105 | 106 | // Return redirect to Cognito Hosted UI for sign-in 107 | const response = { 108 | status: "307", 109 | statusDescription: "Temporary Redirect", 110 | headers: { 111 | location: [ 112 | { 113 | key: "location", 114 | value: `https://${CONFIG.cognitoAuthDomain}/oauth2/authorize?${loginQueryString}`, 115 | }, 116 | ], 117 | "set-cookie": [ 118 | ...getNonceCookies({ nonce, ...CONFIG }), 119 | { 120 | key: "set-cookie", 121 | value: `spa-auth-edge-pkce=${encodeURIComponent(state.pkce)}; ${ 122 | CONFIG.cookieSettings.nonce 123 | }`, 124 | }, 125 | ], 126 | ...CONFIG.cloudFrontHeaders, 127 | }, 128 | }; 129 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 130 | return response; 131 | } 132 | 133 | function redirectToRefreshPath({ 134 | domainName, 135 | requestedUri, 136 | }: { 137 | domainName: string; 138 | requestedUri: string; 139 | }) { 140 | const nonce = generateNonce(); 141 | const response = { 142 | status: "307", 143 | statusDescription: "Temporary Redirect", 144 | headers: { 145 | location: [ 146 | { 147 | key: "location", 148 | value: `https://${domainName}${ 149 | CONFIG.redirectPathAuthRefresh 150 | }?${stringifyQueryString({ requestedUri, nonce })}`, 151 | }, 152 | ], 153 | "set-cookie": getNonceCookies({ nonce, ...CONFIG }), 154 | ...CONFIG.cloudFrontHeaders, 155 | }, 156 | }; 157 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 158 | return response; 159 | } 160 | 161 | function showContactAdminErrorPage({ 162 | err, 163 | domainName, 164 | }: { 165 | err: unknown; 166 | domainName: string; 167 | }) { 168 | const response = { 169 | body: common.createErrorHtml({ 170 | title: "Not Authorized", 171 | message: 172 | "You are not authorized for this site. Please contact the admin.", 173 | expandText: "Click for details", 174 | details: `${err}`, 175 | linkUri: `https://${domainName}${CONFIG.signOutUrl}`, 176 | linkText: "Try again", 177 | }), 178 | status: "200", 179 | headers: { 180 | ...CONFIG.cloudFrontHeaders, 181 | "content-type": [ 182 | { 183 | key: "Content-Type", 184 | value: "text/html; charset=UTF-8", 185 | }, 186 | ], 187 | }, 188 | }; 189 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 190 | return response; 191 | } 192 | 193 | function getNonceCookies({ 194 | nonce, 195 | nonceLength, 196 | nonceSigningSecret, 197 | cookieSettings, 198 | }: { 199 | nonce: string; 200 | nonceLength: number; 201 | nonceSigningSecret: string; 202 | cookieSettings: { 203 | nonce: string; 204 | }; 205 | }) { 206 | return [ 207 | { 208 | key: "set-cookie", 209 | value: `spa-auth-edge-nonce=${encodeURIComponent(nonce)}; ${ 210 | cookieSettings.nonce 211 | }`, 212 | }, 213 | { 214 | key: "set-cookie", 215 | value: `spa-auth-edge-nonce-hmac=${encodeURIComponent( 216 | common.sign(nonce, nonceSigningSecret, nonceLength) 217 | )}; ${cookieSettings.nonce}`, 218 | }, 219 | ]; 220 | } 221 | 222 | function generatePkceVerifier() { 223 | const pkce = common.generateSecret( 224 | CONFIG.secretAllowedCharacters, 225 | CONFIG.pkceLength 226 | ); 227 | const verifier = { 228 | pkce, 229 | pkceHash: common.urlSafe.stringify( 230 | createHash("sha256").update(pkce, "utf8").digest("base64") 231 | ), 232 | }; 233 | CONFIG.logger.debug("Generated PKCE verifier:\n", verifier); 234 | return verifier; 235 | } 236 | 237 | function generateNonce() { 238 | const randomString = common.generateSecret( 239 | CONFIG.secretAllowedCharacters, 240 | CONFIG.nonceLength 241 | ); 242 | const nonce = `${common.timestampInSeconds()}T${randomString}`; 243 | CONFIG.logger.debug("Generated new nonce:", nonce); 244 | return nonce; 245 | } 246 | -------------------------------------------------------------------------------- /src/lambda-edge/check-auth/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check-auth", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "check-auth", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambda-edge/check-auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check-auth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda-edge/http-headers/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/http-headers/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { CloudFrontResponseHandler } from "aws-lambda"; 5 | import * as common from "../shared/shared"; 6 | 7 | const CONFIG = common.getConfigWithHeaders(); 8 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 9 | 10 | export const handler: CloudFrontResponseHandler = async (event) => { 11 | CONFIG.logger.debug("Event:", event); 12 | const response = event.Records[0].cf.response; 13 | Object.assign(response.headers, CONFIG.cloudFrontHeaders); 14 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 15 | return response; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lambda-edge/http-headers/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-headers", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "http-headers", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambda-edge/http-headers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-headers", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "bundle.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda-edge/parse-auth/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/parse-auth/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { 5 | parse as parseQueryString, 6 | stringify as stringifyQueryString, 7 | } from "querystring"; 8 | import { CloudFrontRequestHandler } from "aws-lambda"; 9 | import * as common from "../shared/shared"; 10 | 11 | const CONFIG = common.getCompleteConfig(); 12 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 13 | 14 | export const handler: CloudFrontRequestHandler = async (event) => { 15 | CONFIG.logger.debug("Event:", event); 16 | const request = event.Records[0].cf.request; 17 | const domainName = request.headers["host"][0].value; 18 | const cognitoTokenEndpoint = `https://${CONFIG.cognitoAuthDomain}/oauth2/token`; 19 | let redirectedFromUri = `https://${domainName}`; 20 | let idTokenInCookies: string | undefined = undefined; 21 | try { 22 | const cookies = common.extractAndParseCookies( 23 | request.headers, 24 | CONFIG.clientId, 25 | CONFIG.cookieCompatibility 26 | ); 27 | ({ idToken: idTokenInCookies } = cookies); 28 | const { code, pkce, requestedUri } = validateQueryStringAndCookies({ 29 | querystring: request.querystring, 30 | cookies, 31 | }); 32 | CONFIG.logger.debug("Query string and cookies are valid"); 33 | redirectedFromUri += common.ensureValidRedirectPath(requestedUri); 34 | 35 | const body = stringifyQueryString({ 36 | grant_type: "authorization_code", 37 | client_id: CONFIG.clientId, 38 | redirect_uri: `https://${domainName}${CONFIG.redirectPathSignIn}`, 39 | code, 40 | code_verifier: pkce, 41 | }); 42 | 43 | const requestConfig: Parameters< 44 | typeof common.httpPostToCognitoWithRetry 45 | >[2] = { 46 | headers: { 47 | "Content-Type": "application/x-www-form-urlencoded", 48 | }, 49 | }; 50 | if (CONFIG.clientSecret) { 51 | const encodedSecret = Buffer.from( 52 | `${CONFIG.clientId}:${CONFIG.clientSecret}` 53 | ).toString("base64"); 54 | requestConfig.headers!.Authorization = `Basic ${encodedSecret}`; 55 | } 56 | CONFIG.logger.debug("HTTP POST to Cognito token endpoint:\n", { 57 | uri: cognitoTokenEndpoint, 58 | body, 59 | requestConfig, 60 | }); 61 | const { 62 | status, 63 | headers, 64 | data: { 65 | id_token: idToken, 66 | access_token: accessToken, 67 | refresh_token: refreshToken, 68 | }, 69 | } = await common 70 | .httpPostToCognitoWithRetry( 71 | cognitoTokenEndpoint, 72 | Buffer.from(body), 73 | requestConfig, 74 | CONFIG.logger 75 | ) 76 | .catch((err) => { 77 | throw new Error( 78 | `Failed to exchange authorization code for tokens: ${err}` 79 | ); 80 | }); 81 | CONFIG.logger.info("Successfully exchanged authorization code for tokens"); 82 | const response = { 83 | status: "307", 84 | statusDescription: "Temporary Redirect", 85 | headers: { 86 | location: [ 87 | { 88 | key: "location", 89 | value: redirectedFromUri, 90 | }, 91 | ], 92 | "set-cookie": common.generateCookieHeaders.signIn({ 93 | tokens: { 94 | id: idToken, 95 | access: accessToken, 96 | refresh: refreshToken, 97 | }, 98 | ...CONFIG, 99 | }), 100 | ...CONFIG.cloudFrontHeaders, 101 | }, 102 | }; 103 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 104 | return response; 105 | } catch (err) { 106 | CONFIG.logger.error(err); 107 | if (idTokenInCookies) { 108 | // There is an ID token in the cookies - maybe the user signed in already (e.g. in another browser tab) 109 | // We'll redirect the user back to where they came from, and let checkAuth worry about whether the JWT is valid 110 | CONFIG.logger.debug( 111 | "ID token found, redirecting back to:", 112 | redirectedFromUri 113 | ); 114 | // Return user to where he/she came from (the JWT will be checked there) 115 | const response = { 116 | status: "307", 117 | statusDescription: "Temporary Redirect", 118 | headers: { 119 | location: [ 120 | { 121 | key: "location", 122 | value: redirectedFromUri, 123 | }, 124 | ], 125 | ...CONFIG.cloudFrontHeaders, 126 | }, 127 | }; 128 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 129 | return response; 130 | } 131 | let htmlParams: Parameters[0]; 132 | if (err instanceof common.RequiresConfirmationError) { 133 | htmlParams = { 134 | title: "Confirm sign-in", 135 | message: "We need your confirmation to sign you in –– to ensure", 136 | expandText: "your safety", 137 | details: err.toString(), 138 | linkUri: redirectedFromUri, 139 | linkText: "Confirm", 140 | }; 141 | } else { 142 | htmlParams = { 143 | title: "Sign-in issue", 144 | message: "We can't sign you in because of a", 145 | expandText: "technical problem", 146 | details: `${err}`, 147 | linkUri: redirectedFromUri, 148 | linkText: "Try again", 149 | }; 150 | } 151 | const response = { 152 | body: common.createErrorHtml(htmlParams), 153 | status: "200", 154 | headers: { 155 | ...CONFIG.cloudFrontHeaders, 156 | "content-type": [ 157 | { 158 | key: "Content-Type", 159 | value: "text/html; charset=UTF-8", 160 | }, 161 | ], 162 | }, 163 | }; 164 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 165 | return response; 166 | } 167 | }; 168 | 169 | function validateQueryStringAndCookies(props: { 170 | querystring: string; 171 | cookies: ReturnType; 172 | }) { 173 | // Check if Cognito threw an Error. Cognito puts the error in the query string 174 | const { 175 | code, 176 | state, 177 | error: cognitoError, 178 | error_description, 179 | } = parseQueryString(props.querystring); 180 | if (cognitoError) { 181 | throw new Error(`[Cognito] ${cognitoError}: ${error_description}`); 182 | } 183 | 184 | // The querystring needs to have an authorization code and state 185 | if ( 186 | !code || 187 | !state || 188 | typeof code !== "string" || 189 | typeof state !== "string" 190 | ) { 191 | throw new Error( 192 | [ 193 | 'Invalid query string. Your query string does not include parameters "state" and "code".', 194 | "This can happen if your authentication attempt did not originate from this site.", 195 | ].join(" ") 196 | ); 197 | } 198 | 199 | // The querystring state should be a JSON string 200 | let parsedState: { nonce?: string; requestedUri?: string }; 201 | try { 202 | parsedState = JSON.parse( 203 | Buffer.from(common.urlSafe.parse(state), "base64").toString() 204 | ); 205 | } catch { 206 | throw new Error( 207 | 'Invalid query string. Your query string does not include a valid "state" parameter' 208 | ); 209 | } 210 | 211 | // The querystring state needs to include the right pieces 212 | if (!parsedState.requestedUri || !parsedState.nonce) { 213 | throw new Error( 214 | 'Invalid query string. Your query string does not include a valid "state" parameter' 215 | ); 216 | } 217 | 218 | // The querystring state needs to correlate to the cookies 219 | const { nonce: originalNonce, pkce, nonceHmac } = props.cookies; 220 | if ( 221 | !parsedState.nonce || 222 | !originalNonce || 223 | parsedState.nonce !== originalNonce 224 | ) { 225 | if (!originalNonce) { 226 | throw new common.RequiresConfirmationError( 227 | "Your browser didn't send the nonce cookie along, but it is required for security (prevent CSRF)." 228 | ); 229 | } 230 | throw new common.RequiresConfirmationError( 231 | "Nonce mismatch. This can happen if you start multiple authentication attempts in parallel (e.g. in separate tabs)" 232 | ); 233 | } 234 | if (!pkce) { 235 | throw new Error( 236 | "Your browser didn't send the pkce cookie along, but it is required for security (prevent CSRF)." 237 | ); 238 | } 239 | 240 | // Nonce should not be too old 241 | const nonceTimestamp = parseInt( 242 | parsedState.nonce.slice(0, parsedState.nonce.indexOf("T")) 243 | ); 244 | if (common.timestampInSeconds() - nonceTimestamp > CONFIG.nonceMaxAge) { 245 | throw new common.RequiresConfirmationError( 246 | `Nonce is too old (nonce is from ${new Date( 247 | nonceTimestamp * 1000 248 | ).toISOString()})` 249 | ); 250 | } 251 | 252 | // Nonce should have the right signature: proving we were the ones generating it (and e.g. not malicious JS on a subdomain) 253 | const calculatedHmac = common.sign( 254 | parsedState.nonce, 255 | CONFIG.nonceSigningSecret, 256 | CONFIG.nonceLength 257 | ); 258 | if (calculatedHmac !== nonceHmac) { 259 | throw new common.RequiresConfirmationError( 260 | `Nonce signature mismatch! Expected ${calculatedHmac} but got ${nonceHmac}` 261 | ); 262 | } 263 | 264 | return { code, pkce, requestedUri: parsedState.requestedUri ?? "" }; 265 | } 266 | -------------------------------------------------------------------------------- /src/lambda-edge/parse-auth/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-auth", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "parse-auth", 9 | "version": "1.0.0", 10 | "devDependencies": {} 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lambda-edge/parse-auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-auth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": {}, 7 | "devDependencies": {}, 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "keywords": [], 12 | "author": "" 13 | } 14 | -------------------------------------------------------------------------------- /src/lambda-edge/refresh-auth/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/refresh-auth/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { 5 | parse as parseQueryString, 6 | stringify as stringifyQueryString, 7 | } from "querystring"; 8 | import { CloudFrontRequestHandler } from "aws-lambda"; 9 | import * as common from "../shared/shared"; 10 | 11 | const CONFIG = common.getCompleteConfig(); 12 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 13 | 14 | export const handler: CloudFrontRequestHandler = async (event) => { 15 | CONFIG.logger.debug("Event:", event); 16 | const request = event.Records[0].cf.request; 17 | const domainName = request.headers["host"][0].value; 18 | let requestedUri: string | string[] | undefined = "/"; 19 | let idToken: string | undefined = undefined; 20 | 21 | try { 22 | const querySting = parseQueryString(request.querystring); 23 | requestedUri = querySting.requestedUri; 24 | const cookies = common.extractAndParseCookies( 25 | request.headers, 26 | CONFIG.clientId, 27 | CONFIG.cookieCompatibility 28 | ); 29 | idToken = cookies.idToken; 30 | 31 | validateRefreshRequest( 32 | querySting.nonce, 33 | cookies.nonceHmac, 34 | cookies.nonce, 35 | cookies.idToken, 36 | cookies.refreshToken 37 | ); 38 | 39 | const headers: { "Content-Type": string; Authorization?: string } = { 40 | "Content-Type": "application/x-www-form-urlencoded", 41 | }; 42 | 43 | if (CONFIG.clientSecret) { 44 | const encodedSecret = Buffer.from( 45 | `${CONFIG.clientId}:${CONFIG.clientSecret}` 46 | ).toString("base64"); 47 | headers["Authorization"] = `Basic ${encodedSecret}`; 48 | } 49 | 50 | let newIdToken: string | undefined; 51 | let newAccessToken: string | undefined; 52 | const body = stringifyQueryString({ 53 | grant_type: "refresh_token", 54 | client_id: CONFIG.clientId, 55 | refresh_token: cookies.refreshToken, 56 | }); 57 | const res = await common 58 | .httpPostToCognitoWithRetry( 59 | `https://${CONFIG.cognitoAuthDomain}/oauth2/token`, 60 | Buffer.from(body), 61 | { headers }, 62 | CONFIG.logger 63 | ) 64 | .catch((err) => { 65 | throw new Error(`Failed to refresh tokens: ${err}`); 66 | }); 67 | CONFIG.logger.info("Successfully renewed tokens"); 68 | newIdToken = res.data.id_token as string; 69 | newAccessToken = res.data.access_token as string; 70 | const response = { 71 | status: "307", 72 | statusDescription: "Temporary Redirect", 73 | headers: { 74 | location: [ 75 | { 76 | key: "location", 77 | value: `https://${domainName}${common.ensureValidRedirectPath( 78 | requestedUri 79 | )}`, 80 | }, 81 | ], 82 | "set-cookie": common.generateCookieHeaders.refresh({ 83 | ...CONFIG, 84 | tokens: { id: newIdToken, access: newAccessToken }, 85 | }), 86 | ...CONFIG.cloudFrontHeaders, 87 | }, 88 | }; 89 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 90 | return response; 91 | } catch (err) { 92 | if ( 93 | err instanceof Error && 94 | err.message.includes("invalid_grant") && 95 | idToken 96 | ) { 97 | // The refresh token has likely expired. 98 | // We'll clear the refresh token cookie, so that CheckAuth won't redirect any more requests here in vain. 99 | // Also, we'll redirect the user to where he/she came from. 100 | // (From there CheckAuth will redirect the user to the Cognito hosted UI to sign in) 101 | CONFIG.logger.info( 102 | "Expiring refresh token cookie, as the refresh token has expired" 103 | ); 104 | const response = { 105 | status: "307", 106 | statusDescription: "Temporary Redirect", 107 | headers: { 108 | location: [ 109 | { 110 | key: "location", 111 | value: `https://${domainName}${common.ensureValidRedirectPath( 112 | requestedUri 113 | )}`, 114 | }, 115 | ], 116 | "set-cookie": common.generateCookieHeaders.refreshFailed({ 117 | tokens: { 118 | id: idToken, 119 | }, 120 | ...CONFIG, 121 | }), 122 | ...CONFIG.cloudFrontHeaders, 123 | }, 124 | }; 125 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 126 | return response; 127 | } 128 | CONFIG.logger.error(err); 129 | const response = { 130 | body: common.createErrorHtml({ 131 | title: "Refresh issue", 132 | message: "We can't refresh your sign-in automatically because of a", 133 | expandText: "technical problem", 134 | details: `${err}`, 135 | linkUri: `https://${domainName}${CONFIG.signOutUrl}`, 136 | linkText: "Sign in", 137 | }), 138 | status: "200", 139 | headers: { 140 | ...CONFIG.cloudFrontHeaders, 141 | "content-type": [ 142 | { 143 | key: "Content-Type", 144 | value: "text/html; charset=UTF-8", 145 | }, 146 | ], 147 | }, 148 | }; 149 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 150 | return response; 151 | } 152 | }; 153 | 154 | function validateRefreshRequest( 155 | currentNonce?: string | string[], 156 | nonceHmac?: string, 157 | originalNonce?: string, 158 | idToken?: string, 159 | refreshToken?: string 160 | ) { 161 | if (!originalNonce) { 162 | throw new Error( 163 | "Your browser didn't send the nonce cookie along, but it is required for security (prevent CSRF)." 164 | ); 165 | } 166 | if (currentNonce !== originalNonce) { 167 | throw new Error("Nonce mismatch"); 168 | } 169 | if (!idToken) { 170 | throw new Error("Missing ID token"); 171 | } 172 | if (!refreshToken) { 173 | throw new Error("Missing refresh token"); 174 | } 175 | // Nonce should not be too old 176 | const nonceTimestamp = parseInt( 177 | currentNonce.slice(0, currentNonce.indexOf("T")) 178 | ); 179 | if (common.timestampInSeconds() - nonceTimestamp > CONFIG.nonceMaxAge) { 180 | throw new common.RequiresConfirmationError( 181 | `Nonce is too old (nonce is from ${new Date( 182 | nonceTimestamp * 1000 183 | ).toISOString()})` 184 | ); 185 | } 186 | 187 | // Nonce should have the right signature: proving we were the ones generating it (and e.g. not malicious JS on a subdomain) 188 | const calculatedHmac = common.sign( 189 | currentNonce, 190 | CONFIG.nonceSigningSecret, 191 | CONFIG.nonceLength 192 | ); 193 | if (calculatedHmac !== nonceHmac) { 194 | throw new common.RequiresConfirmationError( 195 | `Nonce signature mismatch! Expected ${calculatedHmac} but got ${nonceHmac}` 196 | ); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/lambda-edge/refresh-auth/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refresh-auth", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "refresh-auth", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambda-edge/refresh-auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refresh-auth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda-edge/rewrite-trailing-slash/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/rewrite-trailing-slash/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { CloudFrontRequestHandler } from "aws-lambda"; 5 | import { getConfig } from "../shared/shared"; 6 | 7 | const CONFIG = getConfig(); 8 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 9 | 10 | export const handler: CloudFrontRequestHandler = async (event) => { 11 | CONFIG.logger.debug("Event:", event); 12 | const request = event.Records[0].cf.request; 13 | if (request.uri.endsWith("/")) { 14 | request.uri += "index.html"; 15 | } 16 | CONFIG.logger.debug("Returning request:\n", request); 17 | return request; 18 | }; 19 | -------------------------------------------------------------------------------- /src/lambda-edge/rewrite-trailing-slash/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rewrite-trailing-slash", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "rewrite-trailing-slash", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambda-edge/rewrite-trailing-slash/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rewrite-trailing-slash", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /src/lambda-edge/shared/error-page/html.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /src/lambda-edge/shared/error-page/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 15 | Authorization@Edge 16 | 35 | 36 | 37 |
38 |
Authorization@Edge
39 |
40 |
${title}
41 |

42 | ${message} 43 | 53 |

54 | 57 | ${linkText} 60 |
61 |
62 | 67 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/lambda-edge/shared/https.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { IncomingHttpHeaders } from "http"; 5 | import { request, RequestOptions } from "https"; 6 | import { Writable, pipeline } from "stream"; 7 | 8 | const DEFAULT_REQUEST_TIMEOUT = 4000; // 4 seconds 9 | 10 | export async function fetch( 11 | uri: string, 12 | data?: Buffer, 13 | options?: RequestOptions 14 | ) { 15 | return new Promise<{ 16 | status?: number; 17 | headers: IncomingHttpHeaders; 18 | data: Buffer; 19 | }>((resolve, reject) => { 20 | const requestOptions = { 21 | // @ts-ignore 22 | signal: AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT), 23 | ...(options ?? {}), 24 | }; 25 | 26 | const req = request(uri, requestOptions, (res) => 27 | pipeline( 28 | [ 29 | res, 30 | collectBuffer((data) => 31 | resolve({ status: res.statusCode, headers: res.headers, data }) 32 | ), 33 | ], 34 | done 35 | ) 36 | ); 37 | 38 | function done(error?: Error | null) { 39 | if (!error) return; 40 | req.destroy(error); 41 | reject(error); 42 | } 43 | 44 | req.on("error", done); 45 | 46 | req.end(data); 47 | }); 48 | } 49 | 50 | const collectBuffer = (callback: (collectedBuffer: Buffer) => void) => { 51 | const chunks = [] as Buffer[]; 52 | return new Writable({ 53 | write: (chunk, _encoding, done) => { 54 | try { 55 | chunks.push(chunk); 56 | done(); 57 | } catch (err) { 58 | done(err as Error); 59 | } 60 | }, 61 | final: (done) => { 62 | try { 63 | callback(Buffer.concat(chunks)); 64 | done(); 65 | } catch (err) { 66 | done(err as Error); 67 | } 68 | }, 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/lambda-edge/shared/shared.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { CloudFrontHeaders } from "aws-lambda"; 5 | import { readFileSync } from "fs"; 6 | import { formatWithOptions } from "util"; 7 | import { createHmac, randomInt } from "crypto"; 8 | import { parse } from "cookie"; 9 | import { fetch } from "./https"; 10 | import { Agent, RequestOptions } from "https"; 11 | import html from "./error-page/template.html"; 12 | import { CognitoJwtVerifier } from "aws-jwt-verify"; 13 | import { Jwks } from "aws-jwt-verify/jwk"; 14 | export { 15 | CognitoJwtInvalidGroupError, 16 | JwtExpiredError, 17 | } from "aws-jwt-verify/error"; 18 | 19 | export interface CookieSettings { 20 | idToken: string; 21 | accessToken: string; 22 | refreshToken: string; 23 | nonce: string; 24 | [key: string]: string; 25 | } 26 | 27 | function getDefaultCookieSettings(props: { 28 | mode: "spaMode" | "staticSiteMode"; 29 | compatibility: "amplify" | "elasticsearch"; 30 | redirectPathAuthRefresh: string; 31 | }): CookieSettings { 32 | // Defaults can be overridden by the user (CloudFormation Stack parameter) but should be solid enough for most purposes 33 | if (props.compatibility === "amplify") { 34 | if (props.mode === "spaMode") { 35 | return { 36 | idToken: "Path=/; Secure; SameSite=Lax", 37 | accessToken: "Path=/; Secure; SameSite=Lax", 38 | refreshToken: "Path=/; Secure; SameSite=Lax", 39 | nonce: "Path=/; Secure; HttpOnly; SameSite=Lax", 40 | }; 41 | } else if (props.mode === "staticSiteMode") { 42 | return { 43 | idToken: "Path=/; Secure; HttpOnly; SameSite=Lax", 44 | accessToken: "Path=/; Secure; HttpOnly; SameSite=Lax", 45 | refreshToken: `Path=${props.redirectPathAuthRefresh}; Secure; HttpOnly; SameSite=Lax`, 46 | nonce: "Path=/; Secure; HttpOnly; SameSite=Lax", 47 | }; 48 | } 49 | } else if (props.compatibility === "elasticsearch") { 50 | return { 51 | idToken: "Path=/; Secure; HttpOnly; SameSite=Lax", 52 | accessToken: "Path=/; Secure; HttpOnly; SameSite=Lax", 53 | refreshToken: "Path=/; Secure; HttpOnly; SameSite=Lax", 54 | nonce: "Path=/; Secure; HttpOnly; SameSite=Lax", 55 | cognitoEnabled: "Path=/; Secure; SameSite=Lax", 56 | }; 57 | } 58 | throw new Error( 59 | `Cannot determine default cookie settings for ${props.mode} with compatibility ${props.compatibility}` 60 | ); 61 | } 62 | 63 | export interface HttpHeaders { 64 | [key: string]: string; 65 | } 66 | 67 | type Mode = "spaMode" | "staticSiteMode"; 68 | 69 | interface ConfigFromDisk { 70 | logLevel: keyof typeof LogLevel; 71 | } 72 | 73 | interface ConfigFromDiskWithHeaders extends ConfigFromDisk { 74 | httpHeaders: HttpHeaders; 75 | } 76 | 77 | interface ConfigFromDiskComplete extends ConfigFromDiskWithHeaders { 78 | userPoolArn: string; 79 | jwks: Jwks; 80 | clientId: string; 81 | oauthScopes: string[]; 82 | cognitoAuthDomain: string; 83 | redirectPathSignIn: string; 84 | redirectPathSignOut: string; 85 | signOutUrl: string; 86 | redirectPathAuthRefresh: string; 87 | cookieSettings: CookieSettings; 88 | mode: Mode; 89 | clientSecret: string; 90 | nonceSigningSecret: string; 91 | cookieCompatibility: "amplify" | "elasticsearch"; 92 | additionalCookies: { [name: string]: string }; 93 | requiredGroup: string; 94 | secretAllowedCharacters?: string; 95 | pkceLength?: number; 96 | nonceLength?: number; 97 | nonceMaxAge?: number; 98 | } 99 | 100 | function isConfigWithHeaders(config: any): config is ConfigFromDiskComplete { 101 | return config["httpHeaders"] !== undefined; 102 | } 103 | 104 | function isCompleteConfig(config: any): config is ConfigFromDiskComplete { 105 | return config["userPoolArn"] !== undefined; 106 | } 107 | 108 | enum LogLevel { 109 | "none" = 0, 110 | "error" = 10, 111 | "warn" = 20, 112 | "info" = 30, 113 | "debug" = 40, 114 | } 115 | 116 | class Logger { 117 | constructor(private logLevel: LogLevel) {} 118 | 119 | private format(args: unknown[], depth = 10) { 120 | return args.map((arg) => formatWithOptions({ depth }, arg)).join(" "); 121 | } 122 | 123 | public info(...args: unknown[]) { 124 | if (this.logLevel >= LogLevel.info) { 125 | console.log(this.format(args)); 126 | } 127 | } 128 | public warn(...args: unknown[]) { 129 | if (this.logLevel >= LogLevel.warn) { 130 | console.warn(this.format(args)); 131 | } 132 | } 133 | public error(...args: unknown[]) { 134 | if (this.logLevel >= LogLevel.error) { 135 | console.error(this.format(args)); 136 | } 137 | } 138 | public debug(...args: unknown[]) { 139 | if (this.logLevel >= LogLevel.debug) { 140 | console.trace(this.format(args)); 141 | } 142 | } 143 | } 144 | 145 | export interface Config extends ConfigFromDisk { 146 | logger: Logger; 147 | } 148 | 149 | export interface ConfigWithHeaders extends Config, ConfigFromDiskWithHeaders { 150 | cloudFrontHeaders: CloudFrontHeaders; 151 | } 152 | 153 | export interface CompleteConfig 154 | extends ConfigWithHeaders, 155 | ConfigFromDiskComplete { 156 | cloudFrontHeaders: CloudFrontHeaders; 157 | secretAllowedCharacters: string; 158 | pkceLength: number; 159 | nonceLength: number; 160 | nonceMaxAge: number; 161 | } 162 | 163 | export function getConfig(): Config { 164 | const config = JSON.parse( 165 | readFileSync(`${__dirname}/configuration.json`).toString("utf8") 166 | ) as ConfigFromDisk; 167 | return { 168 | logger: new Logger(LogLevel[config.logLevel]), 169 | ...config, 170 | }; 171 | } 172 | 173 | export function getConfigWithHeaders(): ConfigWithHeaders { 174 | const config = getConfig(); 175 | 176 | if (!isConfigWithHeaders(config)) { 177 | throw new Error("Incomplete config in configuration.json"); 178 | } 179 | 180 | return { 181 | cloudFrontHeaders: asCloudFrontHeaders(config.httpHeaders), 182 | ...config, 183 | }; 184 | } 185 | 186 | export function getCompleteConfig(): CompleteConfig { 187 | const config = getConfigWithHeaders(); 188 | 189 | if (!isCompleteConfig(config)) { 190 | throw new Error("Incomplete config in configuration.json"); 191 | } 192 | 193 | // Derive cookie settings by merging the defaults with the explicitly provided values 194 | const defaultCookieSettings = getDefaultCookieSettings({ 195 | compatibility: config.cookieCompatibility, 196 | mode: config.mode, 197 | redirectPathAuthRefresh: config.redirectPathAuthRefresh, 198 | }); 199 | const cookieSettings = config.cookieSettings 200 | ? (Object.fromEntries( 201 | Object.entries({ 202 | ...defaultCookieSettings, 203 | ...config.cookieSettings, 204 | }).map(([k, v]) => [ 205 | k, 206 | v || defaultCookieSettings[k as keyof CookieSettings], 207 | ]) 208 | ) as CookieSettings) 209 | : defaultCookieSettings; 210 | 211 | // Defaults for nonce and PKCE 212 | const defaults = { 213 | secretAllowedCharacters: 214 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", 215 | pkceLength: 43, // Should be between 43 and 128 - per spec 216 | nonceLength: 16, 217 | nonceMaxAge: 218 | (cookieSettings?.nonce && 219 | parseInt(parse(cookieSettings.nonce.toLowerCase())["max-age"])) || 220 | 60 * 60 * 24, 221 | }; 222 | 223 | return { 224 | ...defaults, 225 | ...config, 226 | cookieSettings, 227 | }; 228 | } 229 | 230 | export function getConfigWithJwtVerifier() { 231 | const config = getCompleteConfig(); 232 | const userPoolId = config.userPoolArn.split("/")[1]; 233 | const jwtVerifier = CognitoJwtVerifier.create({ 234 | userPoolId, 235 | clientId: config.clientId, 236 | tokenUse: "id", 237 | groups: config.requiredGroup || undefined, 238 | }); 239 | 240 | // Optimization: load the JWKS (as it was at deploy-time) into the cache. 241 | // Then, the JWKS does not need to be fetched at runtime, 242 | // as long as only JWTs come by with a kid that is in this cached JWKS: 243 | jwtVerifier.cacheJwks(config.jwks); 244 | 245 | return { 246 | ...config, 247 | jwtVerifier, 248 | }; 249 | } 250 | 251 | type Cookies = { [key: string]: string }; 252 | 253 | function extractCookiesFromHeaders(headers: CloudFrontHeaders) { 254 | // Cookies are present in the HTTP header "Cookie" that may be present multiple times. 255 | // This utility function parses occurrences of that header and splits out all the cookies and their values 256 | // A simple object is returned that allows easy access by cookie name: e.g. cookies["nonce"] 257 | if (!headers["cookie"]) { 258 | return {}; 259 | } 260 | const cookies = headers["cookie"].reduce( 261 | (reduced, header) => Object.assign(reduced, parse(header.value)), 262 | {} as Cookies 263 | ); 264 | 265 | return cookies; 266 | } 267 | 268 | export function asCloudFrontHeaders(headers: HttpHeaders): CloudFrontHeaders { 269 | if (!headers) return {}; 270 | // Turn a regular key-value object into the explicit format expected by CloudFront 271 | return Object.entries(headers).reduce( 272 | (reduced, [key, value]) => 273 | Object.assign(reduced, { 274 | [key.toLowerCase()]: [ 275 | { 276 | key, 277 | value, 278 | }, 279 | ], 280 | }), 281 | {} as CloudFrontHeaders 282 | ); 283 | } 284 | 285 | export function getAmplifyCookieNames( 286 | clientId: string, 287 | cookiesOrUserName: Cookies | string 288 | ) { 289 | const keyPrefix = `CognitoIdentityServiceProvider.${clientId}`; 290 | const lastUserKey = `${keyPrefix}.LastAuthUser`; 291 | let tokenUserName: string; 292 | if (typeof cookiesOrUserName === "string") { 293 | tokenUserName = cookiesOrUserName; 294 | } else { 295 | tokenUserName = cookiesOrUserName[lastUserKey]; 296 | } 297 | return { 298 | lastUserKey, 299 | userDataKey: `${keyPrefix}.${tokenUserName}.userData`, 300 | scopeKey: `${keyPrefix}.${tokenUserName}.tokenScopesString`, 301 | idTokenKey: `${keyPrefix}.${tokenUserName}.idToken`, 302 | accessTokenKey: `${keyPrefix}.${tokenUserName}.accessToken`, 303 | refreshTokenKey: `${keyPrefix}.${tokenUserName}.refreshToken`, 304 | hostedUiKey: "amplify-signin-with-hostedUI", 305 | }; 306 | } 307 | 308 | export function getElasticsearchCookieNames() { 309 | return { 310 | idTokenKey: "ID-TOKEN", 311 | accessTokenKey: "ACCESS-TOKEN", 312 | refreshTokenKey: "REFRESH-TOKEN", 313 | cognitoEnabledKey: "COGNITO-ENABLED", 314 | }; 315 | } 316 | 317 | export function extractAndParseCookies( 318 | headers: CloudFrontHeaders, 319 | clientId: string, 320 | cookieCompatibility: "amplify" | "elasticsearch" 321 | ) { 322 | const cookies = extractCookiesFromHeaders(headers); 323 | if (!cookies) { 324 | return {}; 325 | } 326 | 327 | let cookieNames: { [name: string]: string }; 328 | if (cookieCompatibility === "amplify") { 329 | cookieNames = getAmplifyCookieNames(clientId, cookies); 330 | } else { 331 | cookieNames = getElasticsearchCookieNames(); 332 | } 333 | 334 | return { 335 | tokenUserName: cookies[cookieNames.lastUserKey], 336 | idToken: cookies[cookieNames.idTokenKey], 337 | accessToken: cookies[cookieNames.accessTokenKey], 338 | refreshToken: cookies[cookieNames.refreshTokenKey], 339 | scopes: cookies[cookieNames.scopeKey], 340 | nonce: cookies["spa-auth-edge-nonce"], 341 | nonceHmac: cookies["spa-auth-edge-nonce-hmac"], 342 | pkce: cookies["spa-auth-edge-pkce"], 343 | refreshFailed: cookies["spa-auth-edge-refresh"], 344 | }; 345 | } 346 | 347 | interface GenerateCookieHeadersParam { 348 | clientId: string; 349 | oauthScopes: string[]; 350 | cookieSettings: CookieSettings; 351 | cookieCompatibility: "amplify" | "elasticsearch"; 352 | additionalCookies: { [name: string]: string }; 353 | tokens: { 354 | id: string; 355 | access?: string; 356 | refresh?: string; 357 | }; 358 | } 359 | 360 | export const generateCookieHeaders = { 361 | signIn: ( 362 | param: GenerateCookieHeadersParam & { 363 | tokens: { id: string; access: string; refresh: string }; 364 | } 365 | ) => _generateCookieHeaders({ ...param, scenario: "SIGN_IN" }), 366 | refresh: ( 367 | param: GenerateCookieHeadersParam & { 368 | tokens: { id: string; access: string }; 369 | } 370 | ) => _generateCookieHeaders({ ...param, scenario: "REFRESH" }), 371 | refreshFailed: (param: GenerateCookieHeadersParam) => 372 | _generateCookieHeaders({ ...param, scenario: "REFRESH_FAILED" }), 373 | signOut: (param: GenerateCookieHeadersParam) => 374 | _generateCookieHeaders({ ...param, scenario: "SIGN_OUT" }), 375 | }; 376 | 377 | function _generateCookieHeaders( 378 | param: GenerateCookieHeadersParam & { 379 | scenario: "SIGN_IN" | "SIGN_OUT" | "REFRESH" | "REFRESH_FAILED"; 380 | } 381 | ) { 382 | /** 383 | * Generate cookie headers to set, or clear, cookies with JWTs. 384 | * 385 | * This is centralized in this function because there is logic to determine 386 | * the right cookie names, that we do not want to repeat everywhere. 387 | * 388 | * Note that there are other places besides this helper function where 389 | * cookies can be set (search codebase for "set-cookie"). 390 | */ 391 | 392 | const decodedIdToken = decodeToken(param.tokens.id); 393 | const tokenUserName = decodedIdToken["cognito:username"]; 394 | const userData = JSON.stringify({ 395 | UserAttributes: [ 396 | { 397 | Name: "sub", 398 | Value: decodedIdToken["sub"], 399 | }, 400 | { 401 | Name: "email", 402 | Value: decodedIdToken["email"], 403 | }, 404 | ], 405 | Username: tokenUserName, 406 | }); 407 | 408 | const cookiesToSetOrExpire: Cookies = {}; 409 | const cookieNames = 410 | param.cookieCompatibility === "amplify" 411 | ? getAmplifyCookieNames(param.clientId, tokenUserName) 412 | : getElasticsearchCookieNames(); 413 | 414 | // Set or clear JWTs from the cookies 415 | if (param.scenario === "SIGN_IN") { 416 | // JWTs: 417 | cookiesToSetOrExpire[ 418 | cookieNames.idTokenKey 419 | ] = `${param.tokens.id}; ${param.cookieSettings.idToken}`; 420 | cookiesToSetOrExpire[ 421 | cookieNames.accessTokenKey 422 | ] = `${param.tokens.access}; ${param.cookieSettings.accessToken}`; 423 | cookiesToSetOrExpire[ 424 | cookieNames.refreshTokenKey 425 | ] = `${param.tokens.refresh}; ${param.cookieSettings.refreshToken}`; 426 | // Other cookies: 427 | if ("lastUserKey" in cookieNames) 428 | cookiesToSetOrExpire[ 429 | cookieNames.lastUserKey 430 | ] = `${tokenUserName}; ${param.cookieSettings.idToken}`; 431 | if ("scopeKey" in cookieNames) 432 | cookiesToSetOrExpire[cookieNames.scopeKey] = `${param.oauthScopes.join( 433 | " " 434 | )}; ${param.cookieSettings.accessToken}`; 435 | if ("userDataKey" in cookieNames) 436 | cookiesToSetOrExpire[cookieNames.userDataKey] = `${encodeURIComponent( 437 | userData 438 | )}; ${param.cookieSettings.idToken}`; 439 | if ("hostedUiKey" in cookieNames) 440 | cookiesToSetOrExpire[ 441 | cookieNames.hostedUiKey 442 | ] = `true; ${param.cookieSettings.accessToken}`; 443 | if ("cognitoEnabledKey" in cookieNames) 444 | cookiesToSetOrExpire[ 445 | cookieNames.cognitoEnabledKey 446 | ] = `True; ${param.cookieSettings.cognitoEnabled}`; 447 | // Clear marker for failed refresh 448 | cookiesToSetOrExpire["spa-auth-edge-refresh"] = addExpiry( 449 | param.cookieSettings.nonce 450 | ); 451 | } else if (param.scenario === "REFRESH") { 452 | cookiesToSetOrExpire[ 453 | cookieNames.idTokenKey 454 | ] = `${param.tokens.id}; ${param.cookieSettings.idToken}`; 455 | cookiesToSetOrExpire[ 456 | cookieNames.accessTokenKey 457 | ] = `${param.tokens.access}; ${param.cookieSettings.accessToken}`; 458 | // Clear marker for failed refresh 459 | cookiesToSetOrExpire["spa-auth-edge-refresh"] = addExpiry( 460 | param.cookieSettings.nonce 461 | ); 462 | } else if (param.scenario === "SIGN_OUT") { 463 | // Expire JWTs 464 | cookiesToSetOrExpire[cookieNames.idTokenKey] = addExpiry( 465 | param.cookieSettings.idToken 466 | ); 467 | cookiesToSetOrExpire[cookieNames.accessTokenKey] = addExpiry( 468 | param.cookieSettings.accessToken 469 | ); 470 | cookiesToSetOrExpire[cookieNames.refreshTokenKey] = addExpiry( 471 | param.cookieSettings.refreshToken 472 | ); 473 | // Expire other cookies 474 | if ("lastUserKey" in cookieNames) 475 | cookiesToSetOrExpire[cookieNames.lastUserKey] = addExpiry( 476 | param.cookieSettings.idToken 477 | ); 478 | if ("scopeKey" in cookieNames) 479 | cookiesToSetOrExpire[cookieNames.scopeKey] = addExpiry( 480 | param.cookieSettings.accessToken 481 | ); 482 | if ("userDataKey" in cookieNames) 483 | cookiesToSetOrExpire[cookieNames.userDataKey] = addExpiry( 484 | param.cookieSettings.idToken 485 | ); 486 | if ("hostedUiKey" in cookieNames) 487 | cookiesToSetOrExpire[cookieNames.hostedUiKey] = addExpiry( 488 | param.cookieSettings.accessToken 489 | ); 490 | if ("cognitoEnabledKey" in cookieNames) 491 | cookiesToSetOrExpire[cookieNames.cognitoEnabledKey] = addExpiry( 492 | param.cookieSettings.cognitoEnabled 493 | ); 494 | // Clear marker for failed refresh 495 | cookiesToSetOrExpire["spa-auth-edge-refresh"] = addExpiry( 496 | param.cookieSettings.nonce 497 | ); 498 | } else if (param.scenario === "REFRESH_FAILED") { 499 | // Expire refresh token only 500 | cookiesToSetOrExpire[cookieNames.refreshTokenKey] = addExpiry( 501 | param.cookieSettings.refreshToken 502 | ); 503 | // Add marker for failed refresh 504 | cookiesToSetOrExpire[ 505 | "spa-auth-edge-refresh" 506 | ] = `failed; ${param.cookieSettings.nonce}`; 507 | } 508 | 509 | // Always expire nonce, nonceHmac and pkce 510 | [ 511 | "spa-auth-edge-nonce", 512 | "spa-auth-edge-nonce-hmac", 513 | "spa-auth-edge-pkce", 514 | ].forEach((key) => { 515 | cookiesToSetOrExpire[key] = addExpiry(param.cookieSettings.nonce); 516 | }); 517 | 518 | // Return cookie object in format of CloudFront headers 519 | return Object.entries({ 520 | ...param.additionalCookies, 521 | ...cookiesToSetOrExpire, 522 | }).map(([k, v]) => ({ key: "set-cookie", value: `${k}=${v}` })); 523 | } 524 | 525 | /** 526 | * Expire a cookie by setting its expiration time to the epoch start 527 | * @param cookieSettings The cookie settings to add the expire setting to, for example: "Domain=example.com; Secure; HttpOnly" 528 | * @returns Updated cookie settings that you can use as cookie value, i.e. with leading ; and expire instruction, for example: "; Domain=example.com; Secure; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:00 GMT" 529 | */ 530 | function addExpiry(cookieSettings: string) { 531 | const parts = cookieSettings 532 | .split(";") 533 | .map((part) => part.trim()) 534 | .filter((part) => !part.toLowerCase().startsWith("max-age")) 535 | .filter((part) => !part.toLowerCase().startsWith("expires")); 536 | const expires = `Expires=${new Date(0).toUTCString()}`; 537 | return ["", ...parts, expires].join("; "); 538 | } 539 | 540 | function decodeToken(jwt: string) { 541 | const tokenBody = jwt.split(".")[1]; 542 | return JSON.parse(Buffer.from(tokenBody, "base64url").toString()); 543 | } 544 | 545 | const AGENT = new Agent({ keepAlive: true }); 546 | 547 | class NonRetryableFetchError extends Error {} 548 | 549 | export async function httpPostToCognitoWithRetry( 550 | url: string, 551 | data: Buffer, 552 | options: RequestOptions, 553 | logger: Logger 554 | ) { 555 | let attempts = 0; 556 | while (true) { 557 | ++attempts; 558 | try { 559 | return await fetch(url, data, { 560 | agent: AGENT, 561 | ...options, 562 | method: "POST", 563 | }).then((res) => { 564 | const responseData = res.data.toString(); 565 | logger.debug( 566 | `Response from Cognito:`, 567 | JSON.stringify({ 568 | status: res.status, 569 | headers: res.headers, 570 | data: responseData, 571 | }) 572 | ); 573 | if (!res.headers["content-type"]?.startsWith("application/json")) { 574 | throw new Error( 575 | `Content-Type is ${res.headers["content-type"]}, expected application/json` 576 | ); 577 | } 578 | const parsedResponseData = JSON.parse(responseData); 579 | if (res.status !== 200) { 580 | const errorMessage = 581 | parsedResponseData.error || `Status is ${res.status}, expected 200`; 582 | if (res.status && res.status >= 400 && res.status < 500) { 583 | // No use in retrying client errors 584 | throw new NonRetryableFetchError(errorMessage); 585 | } else { 586 | throw new Error(errorMessage); 587 | } 588 | } 589 | return { 590 | ...res, 591 | data: parsedResponseData, 592 | }; 593 | }); 594 | } catch (err) { 595 | logger.debug(`HTTP POST to ${url} failed (attempt ${attempts}): ${err}`); 596 | if (err instanceof NonRetryableFetchError) { 597 | throw err; 598 | } 599 | if (attempts >= 5) { 600 | // Try 5 times at most 601 | logger.error( 602 | `No success after ${attempts} attempts, seizing further attempts` 603 | ); 604 | throw err; 605 | } 606 | if (attempts >= 2) { 607 | // After attempting twice immediately, do some exponential backoff with jitter 608 | logger.debug( 609 | "Doing exponential backoff with jitter, before attempting HTTP POST again ..." 610 | ); 611 | await new Promise((resolve) => 612 | setTimeout( 613 | resolve, 614 | 25 * (Math.pow(2, attempts) + Math.random() * attempts) 615 | ) 616 | ); 617 | logger.debug("Done waiting, will try HTTP POST again now"); 618 | } 619 | } 620 | } 621 | } 622 | 623 | export function createErrorHtml(props: { 624 | title: string; 625 | message: string; 626 | expandText?: string; 627 | details?: string; 628 | linkUri: string; 629 | linkText: string; 630 | }) { 631 | const params = { ...props, region: process.env.AWS_REGION }; 632 | return html.replace( 633 | /\${([^}]*)}/g, 634 | (_: any, v: keyof typeof params) => escapeHtml(params[v]) ?? "" 635 | ); 636 | } 637 | 638 | function escapeHtml(unsafe: unknown) { 639 | if (typeof unsafe !== "string") { 640 | return undefined; 641 | } 642 | return unsafe 643 | .replace(/&/g, "&") 644 | .replace(//g, ">") 646 | .replace(/"/g, """) 647 | .replace(/'/g, "'"); 648 | } 649 | 650 | export const urlSafe = { 651 | /* 652 | Functions to translate base64-encoded strings, so they can be used: 653 | - in URL's without needing additional encoding 654 | - in OAuth2 PKCE verifier 655 | - in cookies (to be on the safe side, as = + / are in fact valid characters in cookies) 656 | 657 | stringify: 658 | use this on a base64-encoded string to translate = + / into replacement characters 659 | 660 | parse: 661 | use this on a string that was previously urlSafe.stringify'ed to return it to 662 | its prior pure-base64 form. Note that trailing = are not added, but NodeJS does not care 663 | */ 664 | stringify: (b64encodedString: string) => 665 | b64encodedString.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"), 666 | parse: (b64encodedString: string) => 667 | b64encodedString.replace(/-/g, "+").replace(/_/g, "/"), 668 | }; 669 | 670 | export function sign( 671 | stringToSign: string, 672 | secret: string, 673 | signatureLength: number 674 | ) { 675 | const digest = createHmac("sha256", secret) 676 | .update(stringToSign) 677 | .digest("base64") 678 | .slice(0, signatureLength); 679 | const signature = urlSafe.stringify(digest); 680 | return signature; 681 | } 682 | 683 | export function timestampInSeconds() { 684 | return (Date.now() / 1000) | 0; 685 | } 686 | 687 | export class RequiresConfirmationError extends Error {} 688 | 689 | export function generateSecret( 690 | allowedCharacters: string, 691 | secretLength: number 692 | ) { 693 | return [...new Array(secretLength)] 694 | .map(() => allowedCharacters[randomInt(0, allowedCharacters.length)]) 695 | .join(""); 696 | } 697 | 698 | export function ensureValidRedirectPath(path: unknown) { 699 | if (typeof path !== "string") return "/"; 700 | return path.startsWith("/") ? path : `/${path}`; 701 | } 702 | -------------------------------------------------------------------------------- /src/lambda-edge/sign-out/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !bundle.* -------------------------------------------------------------------------------- /src/lambda-edge/sign-out/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import { stringify as stringifyQueryString } from "querystring"; 5 | import { CloudFrontRequestHandler } from "aws-lambda"; 6 | import { 7 | getCompleteConfig, 8 | extractAndParseCookies, 9 | generateCookieHeaders, 10 | createErrorHtml, 11 | } from "../shared/shared"; 12 | 13 | let CONFIG: ReturnType; 14 | 15 | export const handler: CloudFrontRequestHandler = async (event) => { 16 | if (!CONFIG) { 17 | CONFIG = getCompleteConfig(); 18 | CONFIG.logger.debug("Configuration loaded:", CONFIG); 19 | } 20 | CONFIG.logger.debug("Event:", event); 21 | const request = event.Records[0].cf.request; 22 | const domainName = request.headers["host"][0].value; 23 | const cookies = extractAndParseCookies( 24 | request.headers, 25 | CONFIG.clientId, 26 | CONFIG.cookieCompatibility 27 | ); 28 | 29 | if (!cookies.idToken) { 30 | const response = { 31 | body: createErrorHtml({ 32 | title: "Signed out", 33 | message: "You are already signed out", 34 | linkUri: `https://${domainName}${CONFIG.redirectPathSignOut}`, 35 | linkText: "Proceed", 36 | }), 37 | status: "200", 38 | headers: { 39 | ...CONFIG.cloudFrontHeaders, 40 | "content-type": [ 41 | { 42 | key: "Content-Type", 43 | value: "text/html; charset=UTF-8", 44 | }, 45 | ], 46 | }, 47 | }; 48 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 49 | return response; 50 | } 51 | 52 | const qs = { 53 | logout_uri: `https://${domainName}${CONFIG.redirectPathSignOut}`, 54 | client_id: CONFIG.clientId, 55 | }; 56 | 57 | const response = { 58 | status: "307", 59 | statusDescription: "Temporary Redirect", 60 | headers: { 61 | location: [ 62 | { 63 | key: "location", 64 | value: `https://${ 65 | CONFIG.cognitoAuthDomain 66 | }/logout?${stringifyQueryString(qs)}`, 67 | }, 68 | ], 69 | "set-cookie": generateCookieHeaders.signOut({ 70 | tokens: { 71 | id: cookies.idToken, 72 | }, 73 | ...CONFIG, 74 | }), 75 | ...CONFIG.cloudFrontHeaders, 76 | }, 77 | }; 78 | CONFIG.logger.debug("Returning response:\n", JSON.stringify(response)); 79 | return response; 80 | }; 81 | -------------------------------------------------------------------------------- /src/lambda-edge/sign-out/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sign-out", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "sign-out", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/lambda-edge/sign-out/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sign-out", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "" 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "target": "ES2022", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "moduleResolution": "node", 10 | "outDir": "dist" 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const TerserPlugin = require("terser-webpack-plugin"); 4 | 5 | function generateConfig(baseDir, baseConfig) { 6 | return fs 7 | .readdirSync(baseDir, { withFileTypes: true }) 8 | .filter((dirent) => dirent.isDirectory()) 9 | .map((dirent) => path.join(baseDir, dirent.name)) 10 | .filter((dirPath) => fs.existsSync(path.join(dirPath, "package.json"))) 11 | .reduce((acc, dirPath) => { 12 | acc.push({ 13 | ...baseConfig, 14 | entry: `${path.resolve(dirPath, "index.ts")}`, 15 | output: { 16 | filename: "bundle.js", 17 | libraryTarget: "commonjs", 18 | path: path.resolve(dirPath), 19 | }, 20 | }); 21 | return acc; 22 | }, []); 23 | } 24 | 25 | const baseConfig = { 26 | mode: "production", 27 | target: "node", 28 | node: { 29 | __dirname: false, 30 | }, 31 | resolve: { 32 | extensions: [".ts", ".js"], 33 | }, 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.ts$/, 38 | loader: "ts-loader", 39 | exclude: /node_modules/, 40 | }, 41 | ], 42 | }, 43 | optimization: { 44 | minimizer: [ 45 | new TerserPlugin({ 46 | parallel: true, 47 | extractComments: true, 48 | }), 49 | ], 50 | }, 51 | stats: { 52 | errorDetails: true, 53 | }, 54 | }; 55 | 56 | const lambdaEdgeBaseConfig = { 57 | ...baseConfig, 58 | module: { 59 | rules: [ 60 | ...baseConfig.module.rules, 61 | { 62 | test: /\.html$/i, 63 | loader: "html-loader", 64 | options: { 65 | minimize: true, 66 | }, 67 | }, 68 | ], 69 | }, 70 | performance: { 71 | hints: "error", 72 | maxAssetSize: 1048576, // Max size of deployment bundle in Lambda@Edge Viewer Request 73 | maxEntrypointSize: 1048576, // Max size of deployment bundle in Lambda@Edge Viewer Request 74 | }, 75 | }; 76 | 77 | const customResourceBaseConfig = { 78 | ...baseConfig, 79 | ignoreWarnings: [/original-fs/], 80 | }; 81 | 82 | module.exports = [ 83 | ...generateConfig( 84 | path.resolve(__dirname, "src/lambda-edge"), 85 | lambdaEdgeBaseConfig 86 | ), 87 | ...generateConfig( 88 | path.resolve(__dirname, "src/cfn-custom-resources"), 89 | customResourceBaseConfig 90 | ), 91 | ]; 92 | --------------------------------------------------------------------------------