diff --git a/.pylintrc b/.pylintrc index e1fc132c6..779c110cd 100644 --- a/.pylintrc +++ b/.pylintrc @@ -421,7 +421,7 @@ ignore-docstrings=yes ignore-imports=yes # Minimum lines number of a similarity. -min-similarity-lines=5 +min-similarity-lines=8 [MISCELLANEOUS] diff --git a/.vscode/settings.json b/.vscode/settings.json index 24d461c65..f7aedfd95 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,10 @@ "python.pythonPath": "${workspaceFolder}/.venv/bin/python", "cSpell.words": [ "Ngin", + "CFNgin", + "JWKS", + "PKCE", + "boto", "cfngin", "cygwin", "dockerizepip", @@ -25,7 +29,8 @@ "kwarg", "lambci", "nondockerizepip", - "stubbers" + "stubbers", + "rxref" ], "pythonTestExplorer.testFramework": "pytest" } diff --git a/docs/source/images/staticsite/auth_at_edge/flow_diagram.png b/docs/source/images/staticsite/auth_at_edge/flow_diagram.png new file mode 100644 index 000000000..c259577d3 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/flow_diagram.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/acm-arn.png b/docs/source/images/staticsite/auth_at_edge/quickstart/acm-arn.png new file mode 100644 index 000000000..7bac41037 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/acm-arn.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-arn.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-arn.png new file mode 100644 index 000000000..144d0af08 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-arn.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-form.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-form.png new file mode 100644 index 000000000..8f4e08e4c Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-form.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-pool.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-pool.png new file mode 100644 index 000000000..25a1df3b7 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user-pool.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user.png new file mode 100644 index 000000000..4231c5697 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-create-user.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-defaults.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-defaults.png new file mode 100644 index 000000000..604b32519 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-defaults.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-home.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-home.png new file mode 100644 index 000000000..b9e5dcc0e Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-home.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-manage-user-pools.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-manage-user-pools.png new file mode 100644 index 000000000..3ceabb8e5 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-manage-user-pools.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-name-and-defaults.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-name-and-defaults.png new file mode 100644 index 000000000..ee973c81f Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-name-and-defaults.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-temporary-password.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-temporary-password.png new file mode 100644 index 000000000..a6883b5b3 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-temporary-password.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-users-and-groups.png b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-users-and-groups.png new file mode 100644 index 000000000..ff0faffbf Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/cognito-users-and-groups.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/site-change-password.png b/docs/source/images/staticsite/auth_at_edge/quickstart/site-change-password.png new file mode 100644 index 000000000..994cd394a Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/site-change-password.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/site-home.png b/docs/source/images/staticsite/auth_at_edge/quickstart/site-home.png new file mode 100644 index 000000000..df3a18a91 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/site-home.png differ diff --git a/docs/source/images/staticsite/auth_at_edge/quickstart/site-login.png b/docs/source/images/staticsite/auth_at_edge/quickstart/site-login.png new file mode 100644 index 000000000..951270dc5 Binary files /dev/null and b/docs/source/images/staticsite/auth_at_edge/quickstart/site-login.png differ diff --git a/docs/source/module_configuration/staticsite.rst b/docs/source/module_configuration/staticsite.rst index 28b83c65b..7c58e794b 100644 --- a/docs/source/module_configuration/staticsite.rst +++ b/docs/source/module_configuration/staticsite.rst @@ -3,16 +3,293 @@ Static Site =========== +Parameters +---------- + +**namespace (str)** + The unique namespace for the deployment. + + Example: + + .. code-block:: yaml + + namespace: my-awesome-website-${env DEPLOY_ENVIRONMENT} + +**staticsite_aliases (Optional[str])** + Any custom domains that you would like to use for the CloudFront distribution created. This should be represented as a comma separated string of domains. Requires also specifying either ``staticsite_acmcert_arn`` or ``staticsite_acmcert_ssm_param`` as well. + + Example: + + .. code-block:: yaml + + staticsite_aliases: example.com,foo.example.com + +**staticsite_acmcert_arn (Optional[str])** + The certificate arn used for any alias domains supplied. This is a requirement when supplying any custom domain unless ``staticsite_acmcert_ssm_param`` is specified. + + Example: + + .. code-block:: yaml + + staticsite_acmcert_arn: arn:aws:acm:us-east-1:123456789012:certificate/... + +**staticsite_acmcert_ssm_param (Optional[str])** + The certificate ARN can be looked up dynamically via SSM. This is a requirement when supplying any custom domain unless ``staticsite_acmcert_arn`` is specified. + + Example: + + .. code-block:: yaml + + staticsite_acmcert_arn: us-west-2@MySSMParamName + +**staticsite_enable_cf_logging (Optional[bool])** + Defaults to ``true``, allows the user to specify if they would like logging performed on their CloudFront distribution. + + Example: + + .. code-block:: yaml + + staticsite_enable_cf_logging: true + +**staticsite_cf_disable (Optional[bool])** + Defaults to ``false``, allows the user to omit having a CloudFront Distribution launched with the stack instance. Useful for a development site as it makes it accessible via an S3 url with a much shorter launch time. This cannot be set to ``true`` when using `Auth@Edge`_ + + Example: + + .. code-block:: yaml + + staticsite_cf_disable: false + +**staticsite_web_acl (Optional[str])** + A web access control list (web ACL) gives you fine-grained control over the web requests that your CloudFront Distribution responds to. Supplying the ARN will associate it with the launched distribution. + + Example: + + .. code-block:: yaml + + staticsite_web_acl: arn:aws:waf::123456789012:certificate/... + +**staticsite_rewrite_directory_index (Optional[str])** + Deploy a Lambda@Edge function designed to rewrite directory indexes, e.g. supports accessing urls such as ``example.org/foo/`` + + Example: + + .. code-block:: yaml + + staticsite_rewrite_directory_index: index.html + +**staticsite_auth_at_edge (Optional[bool])** + Auth@Edge gives the user the ability to privatize a static site behind an authorization wall. For more information review `Auth@Edge`_ + + Example: + + .. code-block:: yaml + + staticsite_auth_at_edge: true + +**staticsite_user_pool_arn (Optional[str])** + Required if ``staticsite_auth_at_edge`` is ``true``. A pre-existing Cognito User Pool is required for user authentication. + + Example: + + .. code-block:: yaml + + staticsite_user_pool_arn: arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_aBcDeF123 + +**staticsite_supported_identity_providers (Optional[str])** + A comma separated list of the User Pool client (generated by Runway) identity providers. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. Defaults to ``COGNITO``. + + Example: + + .. code-block:: yaml + + staticsite_supported_identity_providers: facebook,onelogin + +**staticsite_redirect_path_sign_in (Optional[str])** + Defaults to ``/parseauth``. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. The path that the user is redirected to after sign-in. This corresponds with the ``parseauth`` Lambda@Edge function which will parse the authentication details and verify the reception. + + Example: + + .. code-block:: yaml + + staticsite_redirect_path_sign_in: /parseauth + +**staticsite_redirect_path_sign_out (Optional[str])** + Defaults to ``/``. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. The path that the user is redirected to after sign-out. This typically should be the root of the site as the user will be asked to re-login. + + Example: + + .. code-block:: yaml + + staticsite_redirect_path_sign_out: / + +**staticsite_redirect_path_auth_refresh (Optional[str])** + Defaults to ``/refreshauth``. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. The path that the user is redirected to when their authorization tokens have expired (1 hour). + + Example: + + .. code-block:: yaml + + staticsite_redirect_path_auth_refresh: /refreshauth + +**staticsite_sign_out_url (Optional[str])** + Defaults to ``/signout``. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. The path the user should access to sign themselves out of the application. + + Example: + + .. code-block:: yaml + + staticsite_sign_out_url: /signout + +**staticsite_http_headers (Optional[Dict[str, str]])** + Default is supplied in the example. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. These are the headers that should be sent with each origin response. Please note that the Content-Security-Policy is intentionally lax to allow for Single Page Application framework's to work as expected. Review your Content Security Policy for your project and update these as need be to match. + + Example: + + .. code-block:: yaml + + staticsite_http_headers: + "Content-Security-Policy": "default-src https: 'unsafe-eval' 'unsafe-inline'; font-src 'self' 'unsafe-inline' 'unsafe-eval' data: https:; object-src 'none'; connect-src 'self' https://*.amazonaws.com https://*.amazoncognito.com", + "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", + "Referrer-Policy": "same-origin", + "X-XSS-Protection": "1; mode=block", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + +**staticsite_cookie_settings (Optional[Dict[str, str]])** + Default is supplied in the example. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. The default cookie settings for retrieved tokens and generated nonce's. + + Example: + + .. code-block:: yaml + + staticsite_cookie_settings: + idToken: "Path=/; Secure; SameSite=Lax", + accessToken: "Path=/; Secure; SameSite=Lax", + refreshToken: "Path=/; Secure; SameSite=Lax", + nonce: "Path=/; Secure; HttpOnly; Max-Age=1800; SameSite=Lax", + +**staticsite_oauth_scopes (Optional[List[str]])** + Default is supplied in the example. ``staticsite_auth_at_edge`` must be set to ``true`` for this to take effect. Scope is a mechanism in OAuth 2.0 to limit an application's access to a user's account. An application can request one or more scopes, this information is then presented to the user in the consent screen, and the access token issued to the application will be limited to the scopes granted. + + Example: + + .. code-block:: yaml + + staticsite_oauth_scopes: + - phone + - email + - profile + - openid + - aws.cognito.signin.user.admin' + +**staticsite_lambda_function_associations (Optional[List[Dict[str, str]]])** + This section allows the user to deploy custom `Lambda@Edge` associations with their pre-build function versions. This takes precedence over ``staticsite_rewrite_directory_index`` and cannot currently be used with ``staticsite_auth_at_edge``. + + Example: + + .. code-block:: yaml + + staticsite_lambda_function_associations: + - type: origin-request + arn: arn:aws:lambda:us-east-1:123456789012:function:foo:1 + +**staticsite_custom_error_responses (Optional[List[Dict[str, str]]])** + Allows for customization of error responses. + + Example: + + .. code-block:: yaml + + staticsite_custom_error_responses: + - ErrorCode: 404 + ResponseCode: 200 + ResponsePagePath: /index.html + +**staticsite_non_spa (Optional[bool])** + By default the Auth@Edge implementation assumes that you are running a + single page application as your static site. A custom error response + directing ``ErrorCode: 404`` to the primary ``/index.html`` as a ``ResponseCode: 200`` is added, allowing the SPA to take over error + handling. If you are not running a SPA setting this to ``true`` will + prevent this custom error from being added. If any additions are made + to ``staticsite_custom_error_responses`` those take precedence over + this setting and the default. + + Example: + + .. code-block:: yaml + + staticsite_non_spa: true + +Options +------- + +**pre_build_steps (Optional[List[Dict[str, str]]])** + Commands to be run before generating the hash of files. + + Example: + + .. code-block:: yaml + + pre_build_steps: + - command: npm ci + cwd: ../myothermodule # directory relative to top-level path setting + - command: npm run export + cwd: ../myothermodule + +**source_hashing (Optional[Dict[str, str]])** + Overrides for source hash collection and tracking + + Example: + + .. code-block:: yaml + + source_hashing: + enabled: true # if false, build & upload will occur on every deploy + parameters: /${namespace}/myparam # defaults to --hash + directories: # overrides default hash directory of top-level path setting + - path: ./ + - path: ../common + # Additional (gitignore-format) exclusions to + # hashing (.giignore files are loaded automatically) + exclusions: + - foo/* + +**build_steps (Optional[List[str]])** + The steps to run during the build portion of deployment. + + Example: + + .. code-block:: yaml + + build_steps: + - npm ci + - npm run build + +**build_output (Optional[str])** + Overrides default directory of top-level path setting. + + Example: + + .. code-block:: yaml + + build_output: dist + +Description +----------- + This module type performs idempotent deployments of static websites. It combines CloudFormation stacks (for S3 buckets & CloudFront Distribution) with additional logic to build & sync the sites. -It can be used with a configuration like the following:: +It can be used with a configuration like the following: + +.. code-block:: yaml deployments: - modules: - path: web - class_path: runway.module.staticsite.StaticSite + type: static parameters: namespace: contoso-dev staticsite_aliases: web.example.com,foo.web.example.com @@ -43,71 +320,45 @@ Incorporating CloudFront with a static site is a common best practice, however, if you are working on a development project it may benefit you to add the `staticsite_cf_disable` environment parameter set to `true`. -.. _staticsite-config-options: - -Example of all Static Site Options ----------------------------------- +`Auth@Edge` +----------- -Most of these options are not required, but are listed here for reference:: +`Auth@Edge`_ allows the user to make their staticsite private, authenticated by +users in Cognito (which supports local users and/or federated identity providers). The solution is inspired +by similar ones such as `aws-samples/cloudfront-authorization-at-edge `_. - deployments: - - modules: - - name: conduitsite # defaults to path; used in stack names & ssm parameter - path: web - class_path: runway.module.staticsite.StaticSite - parameters: - # The only required parameter value is namespace - namespace: contoso-${env DEPLOY_ENVIRONMENT} - staticsite_acmcert_arn: arn:aws:acm:us-east-1:123456789012:certificate/... +The following diagram depicts a high-level overview of this solution. - # A cert ARN can also be looked up dynamically via SSM - staticsite_acmcert_arn: ${ssm MySSMParamName...} +.. image:: ../images/staticsite/auth_at_edge/flow_diagram.png - staticsite_aliases: example.com,foo.example.com - staticsite_web_acl: arn:aws:waf::123456789012:webacl/... +Here is how the solution works: - # staticsite_enable_cf_logging defaults to true - staticsite_enable_cf_logging: true +1. The viewer’s web browser is redirected to Amazon Cognito custom UI page to sign up and authenticate. +2. After authentication, Cognito generates and cryptographically signs a JWT then responds with a redirect containing the JWT embedded in the URL. +3. The viewer’s web browser extracts JWT from the URL and makes a request to private content (private/* path), adding Authorization request header with JWT. +4. Amazon CloudFront routes the request to the nearest AWS edge location. The CloudFront distribution’s private behavior is configured to launch a `Lambda@Edge` function on ViewerRequest event. +5. `Lambda@Edge` decodes the JWT and checks if the user belongs to the correct Cognito User Pool. It also verifies the cryptographic signature using the public RSA key for Cognito User Pool. Crypto verification ensures that JWT was created by the trusted party. +6. After passing all of the verification steps, `Lambda@Edge` strips out the Authorization header and allows the request to pass through to designated origin for CloudFront. In this case, the origin is the private content Amazon S3 bucket. +7. After receiving response from the origin S3 bucket, CloudFront sends the response back to the browser. The browser displays the data from the returned response. - # Deploy Lambda@Edge to rewrite directory indexes - # e.g. support accessing example.org/foo/ - staticsite_rewrite_directory_index: index.html +An example of a `Auth@Edge`_ static site configuration is as follows. All listed options are required: - # You can also deploy custom Lambda@Edge associations with your - # pre-built function versions - # (this takes precedence over staticsite_rewrite_directory_index) - staticsite_lambda_function_associations: - - type: origin-request - arn: arn:aws:lambda:us-east-1:123456789012:function:foo:1 +.. code-block:: yaml - # Custom error response options can be defined - staticsite_custom_error_responses: - - ErrorCode: 404 - ResponseCode: 200 - ResponsePagePath: /index.html + deployments: + - modules: + - path: sample-app + type: static + parameters: + dev: + namespace: sample-app-dev + staticsite_auth_at_edge: true + staticsite_user_pool_arn: arn:aws:cognito-idp:us-east-1:240134083525:userpool/us-east-1_cjVgcUyWV + regions: + # NOTE: Much like ACM certificates used with CloudFront, + # Auth@Edge sites must be deployed to us-east-1 + - us-east-1 - # Don't use CloudFront with the site - # i.e. for a development site accessible only via its S3-url - statisite_cf_disable: true - options: - pre_build_steps: # commands to run before generating hash of files - - command: npm ci - cwd: ../myothermodule # directory relative to top-level path setting - - command: npm run export - cwd: ../myothermodule - source_hashing: # overrides for source hash collection/tracking - enabled: true # if false, build & upload will occur on every deploy - parameter: /${namespace}/myparam # defaults to --hash - directories: # overrides default hash directory of top-level path setting - - path: ./ - - path: ../common - # Additional (gitignore-format) exclusions to hashing - # (.gitignore files are loaded automatically) - exclusions: - - foo/* - build_steps: - - npm ci - - npm run build - build_output: dist # overrides default directory of top-level path setting - regions: - - us-west-2 +The `Auth@Edge`_ functionality uses your existing Cognito User Pool (optionally configured +with federated identity providers). A user pool app client will be automatically created +within the pool for the application's use. diff --git a/docs/source/quickstart/index.rst b/docs/source/quickstart/index.rst index 6fdf41257..c0c0414f9 100644 --- a/docs/source/quickstart/index.rst +++ b/docs/source/quickstart/index.rst @@ -9,3 +9,4 @@ Quickstart Guides cloudformation conduit other_ways_to_use + private_static_site diff --git a/docs/source/quickstart/private_static_site.rst b/docs/source/quickstart/private_static_site.rst new file mode 100644 index 000000000..96e59907a --- /dev/null +++ b/docs/source/quickstart/private_static_site.rst @@ -0,0 +1,184 @@ +.. qs-aae: + +Private Static Site (`Auth@Edge`) Quickstart +============================================ + +Deploying the Private Static Site +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The Runway built-in sample generation of a basic React app will be used as a simple demonstration of creating an authentication backed single page application. + +Prerequisites +^^^^^^^^^^^^^ + +- An AWS account, and configured terminal environment for interacting with it + with an admin role. +- The following installed tools: + + - npm + - yarn + - git (Available out of the box on macOS) + +Setup +^^^^^ + +Project Setup +~~~~~~~~~~~~~ + +#. Download/install Runway. Here we are showing the :ref:`curl` + option. To see other available install methods, see + :ref:`Installation`. + + .. rubric:: macOS + + .. code-block:: shell + + curl -L https://oni.ca/runway/latest/osx -o runway + chmod +x runway + + .. rubric:: Ubuntu + + .. code-block:: shell + + curl -L https://oni.ca/runway/latest/linux -o runway + chmod +x runway + + .. rubric:: Windows + + .. code-block:: shell + + iwr -Uri oni.ca/runway/latest/windows -OutFile runway.exe + +#. From a directory of your choosing run the following to generate a sample React project: + + .. code-block:: shell + + pipenv run runway gen-sample static-react + +#. A new folder will be created entitled ``static-react``. If you'd like your project to have a different name feel free to change it at this time: + + .. code-block:: shell + + mv static-react my-static-site + +#. Change directories into the new project folder and prepare the project directory. See :ref:`Repo Structure` for more details. + + .. code-block:: shell + + cd my-static-site + git init + git checkout -b ENV-dev + +User Pool Setup +~~~~~~~~~~~~~~~ + +#. The default ``runway.yml`` document that is provided with ``gen-sample static-react`` is a good baseline document for deploying a standard static single page application without the need of authentication. In this example we'll be leveraging ``Auth@Edge`` to provide protection to our application, not allowing anyone to view or download site resources without first authenticating. To accomplish this we need to create a Cognito UserPool. Login to your AWS Console and search for `cognito`. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-home.png + +#. Click ``Manage User Pools`` + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-manage-user-pools.png + +#. Click ``Create a user pool`` + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-create-user-pool.png + +#. You will be asked to provide a name for your User Pool. For our example we will be using a default Cognito User Pool, but you can ``Step through settings`` to customize your pool if you so choose. After entering your Pool name click the ``Review defaults`` button. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-name-and-defaults.png + +#. Review all the settings are accurate prior to clicking ``Create pool``. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-defaults.png + +#. Next let's create a test user to verify our authentication functionality after deployment. Click the ``Users and groups`` list link. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-users-and-groups.png + +#. Click ``Create user`` + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-create-user.png + +#. In the form provided give a valid email address for the ``Username (Required)`` and ``Email`` entries. Ensure ``Send an invitation to this new user?`` is checked so you can receive the temporary password to access the site. Click the ``Create user`` button. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-create-user-form.png + +#. Check the email address provided, you should receive a notification email from Cognito with the username and password that will need to be used for initial authentication. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-temporary-password.png + +#. Now we need to retrieve the ARN for the User Pool we just created and add it to the ``deployments -> modules -> environments -> dev`` section of our ``runway.yml`` document. Click the ``General Settings`` list link to retrieve the ARN. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/cognito-arn.png + + .. code-block:: yaml + + staticsite_user_pool_arn: YOUR_USER_POOL_ARN + +Domain Aliases with ACM Certificate +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +#. In this example we are going to be using an alias custom domain name to identify the CloudFront Distribution. This series of steps is **optional**, a domain will still be provided with the Distribution if you choose not to use a custom domain. This guide assumes that you have `already purchased and registered a custom domain `_ and `created and validated an ACM certficate `_. + +#. The ARN of the ACM certificate is required when providing an alias domain name. From the search bar of the AWS console locate ``certificate manager``. In this screen dropdown the details of your issued and validated certificate and locate the ARN. + + .. image:: ../images/staticsite/auth_at_edge/quickstart/acm-arn.png + + +#. Create two entries in the ``runway.yml`` configuration file under the ``deployments -> modules -> environments -> dev`` heading. One for the alias we're looking to provide, and the other for it's ARN: + + .. code-block:: yaml + + staticsite_aliases: YOUR_CUSTOM_DOMAIN_NAMES_COMMA_SEPARATED + staticsite_acmcert_arn: YOUR_ACM_ARN + + +Cleanup +~~~~~~~ + +#. By default the ``gen-sample static-react`` sample ``runway.yaml`` document comes with ``staticsite_cf_disable: true`` added. Due to the nature of the authorization a Distribution is required. Remove this line from your config file. + + +Deploying +^^^^^^^^^ + +Execute ``pipenv run runway deploy``, enter ``y``. Deployment will take some time (mostly waiting for the CloudFront distribution to stabilize). + +The CloudFront domain at which the site can be reached will be displayed near +the last lines of output once deployment is complete, e.g.: + +``staticsite: sync & CF invalidation of E17B5JWPMTX5Z8 (domain ddy1q4je03d7u.cloudfront.net) complete`` + + +Since we're using a custom domain alias the Distribution will also be accessible by that domain. + + +Accessing and Authorizing +^^^^^^^^^^^^^^^^^^^^^^^^^ + +Authorizing +~~~~~~~~~~~ + +#. From your browser enter either the CloudFront Distribution domain or the alias you provided. You will be greeted with the Cognito login screen. Enter the username and temporary password you received in step 9 of `User Pool Setup`_: + + .. image:: ../images/staticsite/auth_at_edge/quickstart/site-login.png + +#. You will be asked to change your password based on the validation requirements you specified when creating the User Pool. Once you have satisified the requirements click ``Send`` + + .. image:: ../images/staticsite/auth_at_edge/quickstart/site-change-password.png + +#. You will be greeted with the default React App home page: + + .. image:: ../images/staticsite/auth_at_edge/quickstart/site-home.png + +Sign-Out +~~~~~~~~ + +#. By default a ``/sign-out`` path is provided to sign out of Cognito. + + +Teardown +^^^^^^^^ + +Execute ``pipenv run runway destroy``, enter ``y``. diff --git a/runway/blueprints/staticsite/auth_at_edge.py b/runway/blueprints/staticsite/auth_at_edge.py new file mode 100644 index 000000000..4a46ef84e --- /dev/null +++ b/runway/blueprints/staticsite/auth_at_edge.py @@ -0,0 +1,420 @@ +"""Blueprint for the Authorization@Edge implementation of a Static Site. + +Described in detail in this blogpost: +https://aws.amazon.com/blogs/networking-and-content-delivery/authorizationedge-how-to-use-lambdaedge-and-json-web-tokens-to-enhance-web-application-security/ +""" + +import logging +from typing import Any, Dict, List # pylint: disable=unused-import + +import awacs.s3 +from awacs.aws import Allow, Principal, Statement +from awacs.helpers.trust import make_simple_assume_policy +from troposphere import (Join, Output, # noqa pylint: disable=unused-import + awslambda, cloudfront, iam, s3) + +from .staticsite import StaticSite + +LOGGER = logging.getLogger('runway') + + +class AuthAtEdge(StaticSite): + """Auth@Edge Blueprint.""" + + IAM_ARN_PREFIX = 'arn:aws:iam::aws:policy/service-role/' + + VARIABLES = { + 'AcmCertificateArn': {'type': str, + 'default': '', + 'description': '(Optional) Cert ARN for site'}, + 'Aliases': {'type': list, + 'default': [], + 'description': '(Optional) Domain aliases the ' + 'distribution'}, + 'LogBucketName': {'type': str, + 'default': '', + 'description': 'S3 bucket for CF logs'}, + 'OAuthScopes': {'type': list, + 'default': [], + 'description': 'OAuth2 Scopes'}, + 'DisableCloudFront': {'type': bool, + 'default': False, + 'description': 'Whether to disable CF'}, + 'PriceClass': {'type': str, + 'default': 'PriceClass_100', # US/Europe + 'description': 'CF price class for the distribution.'}, + 'RedirectPathSignIn': {'type': str, + 'default': '/parseauth', + 'description': 'Auth@Edge: The URL that should ' + 'handle the redirect from Cognito ' + 'after sign-in.'}, + 'RedirectPathAuthRefresh': {'type': str, + 'default': '/refreshauth', + 'description': 'The URL path that should ' + 'handle the JWT refresh request.'}, + 'RewriteDirectoryIndex': {'type': str, + 'default': '', + 'description': 'The path for rewriting the directory index'}, + 'NonSPAMode': {'type': bool, + 'default': False, + 'description': 'Whether Auth@Edge should omit SPA specific settings'}, + 'SignOutUrl': {'type': str, + 'default': '/signout', + 'description': 'The URL path that you can visit to sign-out.'}, + 'WAFWebACL': {'type': str, + 'default': '', + 'description': '(Optional) WAF id to associate with the ' + 'distribution.'}, + 'custom_error_responses': {'type': list, + 'default': [], + 'description': '(Optional) Custom error ' + 'responses.'}, + } + + def create_template(self): + # type: () -> None + """Create the Blueprinted template for Auth@Edge.""" + self.template.set_version('2010-09-09') + self.template.set_description( + 'Authorization@Edge Static Website - Bucket, Lambdas, and Distribution' + ) + + # Resources + bucket = self.add_bucket() + # self.add_bucket_policy(bucket) + oai = self.add_origin_access_identity() + bucket_policy = self.add_cloudfront_bucket_policy(bucket, oai) + # @TODO: Make this available in Auth@Edge + lambda_function_associations = [] + + if self.directory_index_specified: + rewrite_role = self.add_index_rewrite_role() + index_rewrite = self.add_cloudfront_directory_index_rewrite(rewrite_role) + index_rewrite_version = self.add_cloudfront_directory_index_rewrite_version( + index_rewrite + ) + lambda_function_associations = self.get_directory_index_lambda_association( + lambda_function_associations, + index_rewrite_version + ) + + # Auth@Edge Lambdas + lambda_execution_role = self.add_lambda_execution_role() + check_auth_lambda = self.get_auth_at_edge_lambda_and_ver( + 'CheckAuth', + 'Check Authorization information for request', + 'check_auth', + lambda_execution_role + ) + http_headers_lambda = self.get_auth_at_edge_lambda_and_ver( + 'HttpHeaders', + 'Additional Headers added to every response', + 'http_headers', + lambda_execution_role + ) + parse_auth_lambda = self.get_auth_at_edge_lambda_and_ver( + 'ParseAuth', + 'Parse the Authorization Headers/Cookies for the request', + 'parse_auth', + lambda_execution_role + ) + refresh_auth_lambda = self.get_auth_at_edge_lambda_and_ver( + 'RefreshAuth', + 'Refresh the Authorization information when expired', + 'refresh_auth', + lambda_execution_role + ) + sign_out_lambda = self.get_auth_at_edge_lambda_and_ver( + 'SignOut', + 'Sign the User out of the application', + 'sign_out', + lambda_execution_role + ) + + # CloudFront Distribution + distribution_options = self.get_distribution_options( + bucket, + oai, + lambda_function_associations, + check_auth_lambda['version'], + http_headers_lambda['version'], + parse_auth_lambda['version'], + refresh_auth_lambda['version'], + sign_out_lambda['version'] + ) + self.add_cloudfront_distribution( + bucket_policy, + distribution_options + ) + + def get_auth_at_edge_lambda_and_ver(self, + title, # type: str + description, # type: str + handle, # type: str + role # iam.Role + ): # noqa: E124 + # type: (...) -> Dict[str, Any] + """Create a lambda function and its version. + + Args: + title (str): The name of the function in PascalCase + description (str): Description to be displayed in the + lambda panel + handle (str): The underscore separated representation + of the name of the lambda. This handle is used to + determine the handler for the lambda as well as + identify the correct Code hook_data information. + role (IAM.Role): The Lambda Execution Role + """ + function = self.get_auth_at_edge_lambda( + title, + description, + handle, + role + ) + + return { + 'function': function, + 'version': self.add_version(title, function) + } + + def get_auth_at_edge_lambda(self, + title, # type: str + description, # type: str + handler, # type: str + role # type: iam.Role + ): # noqa: E124 + # type: (...) -> awslambda.Function + """Create an Auth@Edge lambda resource. + + Args: + name (str): The name of the function in PascalCase + description (str): Description to be displayed in the + lambda panel + handle (str): The underscore separated representation + of the name of the lambda. This handle is used to + determine the handler for the lambda as well as + identify the correct Code hook_data information. + role (IAM.Role): The Lambda Execution Role + """ + return self.template.add_resource( + awslambda.Function( + title, + Code=self.context.hook_data['aae_lambda_config'][handler], + Description=description, + Handler='__init__.handler', + Role=role.get_att("Arn"), + Runtime='python3.7' + ) + ) + + def add_version(self, + title, # type: str + lambda_function # type: awslambda.Function + ): # noqa E214 + # type: (...) -> awslambda.Version + """Create a version association with a Lambda@Edge function. + + In order to ensure different versions of the function + are appropriately uploaded a hash based on the code of the + lambda is appended to the name. As the code changes so + will this hash value. + + Args: + title (str): The name of the function in PascalCase + role (awslambda.Function): The Lambda function + """ + s3_key = lambda_function.properties['Code'].to_dict()['S3Key'] + code_hash = s3_key.split('.')[0].split('-')[-1] + return self.template.add_resource( + awslambda.Version( + title + 'Ver' + code_hash, + FunctionName=lambda_function.ref() + ) + ) + + def add_lambda_execution_role(self): + # type: () -> iam.Role + """Create the Lambda@Edge execution role.""" + return self.template.add_resource( + iam.Role( + 'LambdaExecutionRole', + AssumeRolePolicyDocument=make_simple_assume_policy( + 'lambda.amazonaws.com', 'edgelambda.amazonaws.com' + ), + ManagedPolicyArns=[ + self.IAM_ARN_PREFIX + 'AWSLambdaBasicExecutionRole' + ] + ) + ) + + def get_distribution_options(self, + bucket, # type: s3.Bucket + oai, # type: cloudfront.CloudFrontOriginAccessIdentity + lambda_funcs, # type: List[cloudfront.LambdaFunctionAssociation] + check_auth_lambda_version, # type: awslambda.Version + http_headers_lambda_version, # type: awslambda.Version + parse_auth_lambda_version, # type: awslambda.Version + refresh_auth_lambda_version, # type: awslambda.Version + sign_out_lambda_version # type: awslambda.Version + ): # noqa: E124 + # type: (...) -> Dict[str, Any] + """Retrieve the options for our CloudFront distribution. + + Keyword Args: + bucket (dict): The bucket resource + oai (dict): The origin access identity resource + + Return: + dict: The CloudFront Distribution Options + + """ + variables = self.get_variables() + + default_cache_behavior_lambdas = lambda_funcs + default_cache_behavior_lambdas.append( + cloudfront.LambdaFunctionAssociation( + EventType='viewer-request', + LambdaFunctionARN=check_auth_lambda_version.ref() + ) + ) + default_cache_behavior_lambdas.append( + cloudfront.LambdaFunctionAssociation( + EventType='origin-response', + LambdaFunctionARN=http_headers_lambda_version.ref() + ) + ) + + return { + 'Aliases': self.add_aliases(), + 'Origins': [ + cloudfront.Origin( + DomainName=Join( + '.', + [bucket.ref(), + 's3.amazonaws.com']), + S3OriginConfig=cloudfront.S3OriginConfig( + OriginAccessIdentity=Join( + '', + ['origin-access-identity/cloudfront/', + oai.ref()]) + ), + Id='protected-bucket' + ) + ], + 'CacheBehaviors': [ + cloudfront.CacheBehavior( + PathPattern=variables['RedirectPathSignIn'], + Compress=True, + ForwardedValues=cloudfront.ForwardedValues( + QueryString=True + ), + LambdaFunctionAssociations=[ + cloudfront.LambdaFunctionAssociation( + EventType='viewer-request', + LambdaFunctionARN=parse_auth_lambda_version.ref() + ) + ], + TargetOriginId='protected-bucket', + ViewerProtocolPolicy="redirect-to-https" + ), + cloudfront.CacheBehavior( + PathPattern=variables['RedirectPathAuthRefresh'], + Compress=True, + ForwardedValues=cloudfront.ForwardedValues( + QueryString=True + ), + LambdaFunctionAssociations=[ + cloudfront.LambdaFunctionAssociation( + EventType='viewer-request', + LambdaFunctionARN=refresh_auth_lambda_version.ref() + ) + ], + TargetOriginId='protected-bucket', + ViewerProtocolPolicy="redirect-to-https" + ), + cloudfront.CacheBehavior( + PathPattern=variables['SignOutUrl'], + Compress=True, + ForwardedValues=cloudfront.ForwardedValues( + QueryString=True + ), + LambdaFunctionAssociations=[ + cloudfront.LambdaFunctionAssociation( + EventType='viewer-request', + LambdaFunctionARN=sign_out_lambda_version.ref() + ) + ], + TargetOriginId='protected-bucket', + ViewerProtocolPolicy="redirect-to-https" + ), + ], + 'DefaultCacheBehavior': cloudfront.DefaultCacheBehavior( + AllowedMethods=['GET', 'HEAD'], + Compress=True, + DefaultTTL='86400', + ForwardedValues=cloudfront.ForwardedValues( + QueryString=True, + ), + LambdaFunctionAssociations=default_cache_behavior_lambdas, + TargetOriginId='protected-bucket', + ViewerProtocolPolicy='redirect-to-https' + ), + 'DefaultRootObject': 'index.html', + 'Logging': self.add_logging_bucket(), + 'PriceClass': variables['PriceClass'], + 'Enabled': True, + 'WebACLId': self.add_web_acl(), + 'CustomErrorResponses': self._get_error_responses(), + 'ViewerCertificate': self.add_acm_cert() + } + + def _get_error_responses(self): + """Return error response based on site stack variables. + + When custom_error_responses are defined return those, if running + in NonSPAMode return nothing, or return the standard error responses + for a SPA. + """ + variables = self.get_variables() + if variables['custom_error_responses']: + return [ + cloudfront.CustomErrorResponse( + ErrorCode=response['ErrorCode'], + ResponseCode=response['ResponseCode'], + ResponsePagePath=response['ResponsePagePath'] + ) for response in variables['custom_error_responses'] + ] + if variables['NonSPAMode']: + return [] + return [ + cloudfront.CustomErrorResponse( + ErrorCode=404, + ResponseCode=200, + ResponsePagePath='/index.html' + ) + ] + + def _get_cloudfront_bucket_policy_statements(self, bucket, oai): + return [ + Statement( + Action=[awacs.s3.GetObject], + Effect=Allow, + Principal=Principal( + 'CanonicalUser', + oai.get_att('S3CanonicalUserId') + ), + Resource=[ + Join('', [bucket.get_att('Arn'), '/*']) + ] + ), + Statement( + Action=[awacs.s3.ListBucket], + Effect=Allow, + Principal=Principal( + 'CanonicalUser', + oai.get_att('S3CanonicalUserId') + ), + Resource=[bucket.get_att('Arn')] + ) + ] diff --git a/runway/blueprints/staticsite/dependencies.py b/runway/blueprints/staticsite/dependencies.py index 9f8d1be42..ccd33c127 100755 --- a/runway/blueprints/staticsite/dependencies.py +++ b/runway/blueprints/staticsite/dependencies.py @@ -2,24 +2,48 @@ """Module with static website supporting infrastructure.""" from __future__ import print_function -from troposphere import AccountId, Join, Output, s3 +import logging import awacs.s3 -from awacs.aws import AWSPrincipal, Allow, Policy, Statement +from awacs.aws import Allow, AWSPrincipal, Policy, Statement +from troposphere import AccountId, Join, Output, cognito, s3 from runway.cfngin.blueprints.base import Blueprint -# from runway.cfngin.blueprints.variables.types import CFNString + +LOGGER = logging.getLogger(__name__) class Dependencies(Blueprint): """Stacker blueprint for creating static website buckets.""" - VARIABLES = {} + VARIABLES = { + 'AuthAtEdge': { + 'type': bool, + 'default': False, + 'description': 'Utilizing Authorization @ Edge' + }, + 'UserPoolId': { + 'type': str, + 'default': '', + 'description': 'User Pool ID for Authorization @ Edge' + }, + 'OAuthScopes': { + 'type': list, + 'default': [ + 'phone', + 'email', + 'profile', + 'openid', + 'aws.cognito.signin.user.admin' + ], + 'description': 'The allowed scopes for OAuth validation' + } + } def create_template(self): """Create template (main function called by Stacker).""" template = self.template - # variables = self.get_variables() + variables = self.get_variables() template.set_version('2010-09-09') template.set_description('Static Website - Dependencies') @@ -86,6 +110,27 @@ def create_template(self): Value=artifacts.ref() )) + if variables['AuthAtEdge']: + client = template.add_resource( + cognito.UserPoolClient( + "AuthAtEdgeClient", + AllowedOAuthFlows=['code'], + # Temporary CallbackURLs as they are required to + # create the resource. These are changed during + # the client_updater hook to the correct + # values. + CallbackURLs=['https://example.temp'], + UserPoolId=variables['UserPoolId'], + AllowedOAuthScopes=variables['OAuthScopes'] + ) + ) + + template.add_output(Output( + 'AuthAtEdgeClient', + Description='Cognito User Pool App Client for Auth @ Edge', + Value=client.ref() + )) + # Helper section to enable easy blueprint -> template generation # (just run `python ` to output the json) diff --git a/runway/blueprints/staticsite/staticsite.py b/runway/blueprints/staticsite/staticsite.py index b7a843d9b..e581b2e40 100755 --- a/runway/blueprints/staticsite/staticsite.py +++ b/runway/blueprints/staticsite/staticsite.py @@ -3,96 +3,50 @@ from __future__ import print_function import hashlib -# https://github.com/PyCQA/pylint/issues/73 -from distutils.version import LooseVersion # noqa pylint: disable=no-name-in-module,import-error -from past.builtins import basestring +import logging +import os +from typing import Any, Dict, List, Union # pylint: disable=unused-import import awacs.s3 import awacs.sts -from awacs.aws import Action, Allow, Policy, PolicyDocument, Principal, Statement - -import troposphere -from troposphere import ( - AWSProperty, And, Equals, If, Join, Not, NoValue, Output, Select, - awslambda, cloudfront, iam, s3 -) +from awacs.aws import (Action, Allow, Policy, PolicyDocument, Principal, + Statement) +from troposphere import (Join, NoValue, Output, awslambda, cloudfront, iam, s3) from runway.cfngin.blueprints.base import Blueprint -from runway.cfngin.blueprints.variables.types import CFNCommaDelimitedList, CFNString from runway.cfngin.context import Context -IAM_ARN_PREFIX = 'arn:aws:iam::aws:policy/service-role/' -if LooseVersion(troposphere.__version__) == LooseVersion('2.4.0'): - # pylint: disable=ungrouped-imports - from troposphere.validators import boolean, priceclass_type - - class S3OriginConfig(AWSProperty): - """Backported s3 origin config class for broken troposphere release.""" - - props = { - 'OriginAccessIdentity': (basestring, False), - } +LOGGER = logging.getLogger('runway') - class Origin(AWSProperty): - """Backported origin config class for broken troposphere release.""" - - props = { - 'CustomOriginConfig': (cloudfront.CustomOriginConfig, False), - 'DomainName': (basestring, True), - 'Id': (basestring, True), - 'OriginCustomHeaders': ([cloudfront.OriginCustomHeader], False), - 'OriginPath': (basestring, False), - 'S3OriginConfig': (S3OriginConfig, False), - } - - class DistributionConfig(AWSProperty): - """Backported cf config class for broken troposphere release.""" - - props = { - 'Aliases': (list, False), - 'CacheBehaviors': ([cloudfront.CacheBehavior], False), - 'Comment': (basestring, False), - 'CustomErrorResponses': ([cloudfront.CustomErrorResponse], False), - 'DefaultCacheBehavior': (cloudfront.DefaultCacheBehavior, True), - 'DefaultRootObject': (basestring, False), - 'Enabled': (boolean, True), - 'HttpVersion': (basestring, False), - 'IPV6Enabled': (boolean, False), - 'Logging': (cloudfront.Logging, False), - 'Origins': ([Origin], True), - 'PriceClass': (priceclass_type, False), - 'Restrictions': (cloudfront.Restrictions, False), - 'ViewerCertificate': (cloudfront.ViewerCertificate, False), - 'WebACLId': (basestring, False), - } +IAM_ARN_PREFIX = 'arn:aws:iam::aws:policy/service-role/' class StaticSite(Blueprint): # pylint: disable=too-few-public-methods """Stacker blueprint for creating S3 bucket and CloudFront distribution.""" VARIABLES = { - 'AcmCertificateArn': {'type': CFNString, + 'AcmCertificateArn': {'type': str, 'default': '', 'description': '(Optional) Cert ARN for site'}, - 'Aliases': {'type': CFNCommaDelimitedList, - 'default': '', + 'Aliases': {'type': list, + 'default': [], 'description': '(Optional) Domain aliases the ' 'distribution'}, - 'DisableCloudFront': {'type': CFNString, - 'default': '', + 'DisableCloudFront': {'type': bool, + 'default': False, 'description': 'Whether to disable CF'}, - 'LogBucketName': {'type': CFNString, + 'LogBucketName': {'type': str, 'default': '', 'description': 'S3 bucket for CF logs'}, - 'PriceClass': {'type': CFNString, + 'PriceClass': {'type': str, 'default': 'PriceClass_100', # US/Europe 'description': 'CF price class for the distribution.'}, - 'RewriteDirectoryIndex': {'type': CFNString, + 'RewriteDirectoryIndex': {'type': str, 'default': '', 'description': '(Optional) File name to ' 'append to directory ' 'requests.'}, - 'WAFWebACL': {'type': CFNString, + 'WAFWebACL': {'type': str, 'default': '', 'description': '(Optional) WAF id to associate with the ' 'distribution.'}, @@ -107,79 +61,81 @@ class StaticSite(Blueprint): # pylint: disable=too-few-public-methods 'associations.'}, } + @property + def aliases_specified(self): + # type: () -> bool + """Aliases are specified conditional.""" + return self.get_variables()['Aliases'] != [''] + + @property + def cf_enabled(self): + # type: () -> bool + """CloudFront enabled conditional.""" + return not self.get_variables().get('DisableCloudFront', False) + + @property + def acm_certificate_specified(self): + # type: () -> bool + """ACM Certification specified conditional.""" + return self.get_variables()['AcmCertificateArn'] != '' + + @property + def cf_logging_enabled(self): + # type: () -> bool + """CloudFront Logging specified conditional.""" + return self.get_variables()['LogBucketName'] != '' + + @property + def directory_index_specified(self): + # type: () -> bool + """Directory Index specified conditional.""" + return self.get_variables()['RewriteDirectoryIndex'] != '' + + @property + def waf_name_specified(self): + # type: () -> bool + """WAF name specified conditional.""" + return self.get_variables()['WAFWebACL'] != '' + def create_template(self): + # type: () -> None """Create template (main function called by Stacker).""" self.template.set_version('2010-09-09') self.template.set_description('Static Website - Bucket and Distribution') - self.add_template_conditions() - # Resources bucket = self.add_bucket() - bucket_policy = self.add_bucket_policy(bucket) # noqa pylint: disable=unused-variable - oai = self.add_origin_access_identity() - allow_access = self.allow_cloudfront_access_on_bucket(bucket, oai) - rewrite_role = self.add_index_rewrite_role() - index_rewrite = self.add_cloudfront_directory_index_rewrite(rewrite_role) - index_rewrite_version = self.add_cloudfront_directory_index_rewrite_version( - index_rewrite - ) - lambda_function_associations = self.get_lambda_associations(index_rewrite_version) - distribution_options = self.get_cloudfront_distribution_options( - bucket, - oai, - lambda_function_associations - ) - distribution = self.add_cloudfront_distribution( # noqa pylint: disable=unused-variable - allow_access, - distribution_options - ) - def add_template_conditions(self): - """Add Template Conditions.""" - variables = self.get_variables() + if self.cf_enabled: + oai = self.add_origin_access_identity() + bucket_policy = self.add_cloudfront_bucket_policy(bucket, oai) + lambda_function_associations = self.get_lambda_associations() - self.template.add_condition( - 'AcmCertSpecified', - And(Not(Equals(variables['AcmCertificateArn'].ref, '')), - Not(Equals(variables['AcmCertificateArn'].ref, 'undefined'))) - ) - self.template.add_condition( - 'AliasesSpecified', - And(Not(Equals(Select(0, variables['Aliases'].ref), '')), - Not(Equals(Select(0, variables['Aliases'].ref), 'undefined'))) - ) - self.template.add_condition( - 'CFEnabled', - Not(Equals(variables['DisableCloudFront'].ref, 'true')) - ) - self.template.add_condition( - 'CFDisabled', - Equals(variables['DisableCloudFront'].ref, 'true') - ) - self.template.add_condition( - 'CFLoggingEnabled', - And(Not(Equals(variables['LogBucketName'].ref, '')), - Not(Equals(variables['LogBucketName'].ref, 'undefined'))) - ) - self.template.add_condition( - 'DirectoryIndexSpecified', - And(Not(Equals(variables['RewriteDirectoryIndex'].ref, '')), - Not(Equals(variables['RewriteDirectoryIndex'].ref, 'undefined'))) # noqa - ) - self.template.add_condition( - 'CFEnabledAndDirectoryIndexSpecified', - And(Not(Equals(variables['RewriteDirectoryIndex'].ref, '')), - Not(Equals(variables['RewriteDirectoryIndex'].ref, 'undefined')), # noqa - Not(Equals(variables['DisableCloudFront'].ref, 'true'))) - ) - self.template.add_condition( - 'WAFNameSpecified', - And(Not(Equals(variables['WAFWebACL'].ref, '')), - Not(Equals(variables['WAFWebACL'].ref, 'undefined'))) - ) + if self.directory_index_specified: + rewrite_role = self.add_index_rewrite_role() + index_rewrite = self.add_cloudfront_directory_index_rewrite(rewrite_role) + index_rewrite_version = self.add_cloudfront_directory_index_rewrite_version( + index_rewrite + ) + lambda_function_associations = self.get_directory_index_lambda_association( + lambda_function_associations, + index_rewrite_version + ) - def get_lambda_associations(self, directory_index_rewrite_version): + distribution_options = self.get_cloudfront_distribution_options( + bucket, + oai, + lambda_function_associations + ) + distribution = self.add_cloudfront_distribution( # noqa pylint: disable=unused-variable + bucket_policy, + distribution_options + ) + else: + self.add_bucket_policy(bucket) + + def get_lambda_associations(self): + # type: () -> List[cloudfront.LambdaFunctionAssociation] """Retrieve any lambda associations from the instance variables. Keyword Args: @@ -199,18 +155,33 @@ def get_lambda_associations(self, directory_index_rewrite_version): LambdaFunctionARN=x['arn'] ) for x in variables['lambda_function_associations'] ] + return [] - # otherwise fallback to pure CFN condition - return If( - 'DirectoryIndexSpecified', - [cloudfront.LambdaFunctionAssociation( + def get_directory_index_lambda_association( # pylint: disable=no-self-use + self, + lambda_associations, # List[cloudfront.LambdaFunctionAssociation] + directory_index_rewrite_version): # awslambda.Version + # type: (...) -> List[cloudfront.LambdaFunctionAssociation] + """Retrieve the directory index lambda associations with the added rewriter. + + Args: + lambda_associations [List(Any)]: The lambda associations + directory_index_rewrite_version [Any]: The directory index rewrite version + """ + lambda_associations.append( + cloudfront.LambdaFunctionAssociation( EventType='origin-request', LambdaFunctionARN=directory_index_rewrite_version.ref() - )], - NoValue + ) ) + return lambda_associations - def get_cloudfront_distribution_options(self, bucket, oai, lambda_function_associations): + def get_cloudfront_distribution_options( + self, + bucket, # type: s3.Bucket + oai, # type: cloudfront.CloudFrontOriginAccessIdentity + lambda_function_associations): # List[cloudfront.LambdaFunctionAssociation] + # type: (...) -> Dict[str, Any] """Retrieve the options for our CloudFront distribution. Keyword Args: @@ -224,18 +195,14 @@ def get_cloudfront_distribution_options(self, bucket, oai, lambda_function_assoc """ variables = self.get_variables() return { - 'Aliases': If( - 'AliasesSpecified', - variables['Aliases'].ref, - NoValue - ), + 'Aliases': self.add_aliases(), 'Origins': [ - get_cf_origin_class()( + cloudfront.Origin( DomainName=Join( '.', [bucket.ref(), 's3.amazonaws.com']), - S3OriginConfig=get_s3_origin_conf_class()( + S3OriginConfig=cloudfront.S3OriginConfig( OriginAccessIdentity=Join( '', ['origin-access-identity/cloudfront/', @@ -257,33 +224,57 @@ def get_cloudfront_distribution_options(self, bucket, oai, lambda_function_assoc ViewerProtocolPolicy='redirect-to-https' ), 'DefaultRootObject': 'index.html', - 'Logging': If( - 'CFLoggingEnabled', - cloudfront.Logging( - Bucket=Join('.', - [variables['LogBucketName'].ref, - 's3.amazonaws.com']) - ), - NoValue - ), - 'PriceClass': variables['PriceClass'].ref, + 'Logging': self.add_logging_bucket(), + 'PriceClass': variables['PriceClass'], + 'CustomErrorResponses': [ + cloudfront.CustomErrorResponse( + ErrorCode=response['ErrorCode'], + ResponseCode=response['ResponseCode'], + ResponsePagePath=response['ResponsePagePath'] + ) for response in variables['custom_error_responses'] + ], 'Enabled': True, - 'WebACLId': If( - 'WAFNameSpecified', - variables['WAFWebACL'].ref, - NoValue - ), - 'ViewerCertificate': If( - 'AcmCertSpecified', - cloudfront.ViewerCertificate( - AcmCertificateArn=variables['AcmCertificateArn'].ref, - SslSupportMethod='sni-only' - ), - NoValue - ) + 'WebACLId': self.add_web_acl(), + 'ViewerCertificate': self.add_acm_cert() } + def add_aliases(self): + # type: () -> Union[List[str], NoValue] + """Add aliases.""" + if self.aliases_specified: + return self.get_variables()['Aliases'] + return NoValue + + def add_web_acl(self): + # type: () -> Union[str, NoValue] + """Add Web ACL.""" + if self.waf_name_specified: + return self.get_variables()['WAFWebACL'] + return NoValue + + def add_logging_bucket(self): + # type: () -> Union[cloudfront.Logging, NoValue] + """Add Logging Bucket.""" + if self.cf_logging_enabled: + return cloudfront.Logging( + Bucket=Join('.', + [self.get_variables()['LogBucketName'], + 's3.amazonaws.com']) + ) + return NoValue + + def add_acm_cert(self): + # type: () -> Union[cloudfront.ViewerCertificate, NoValue] + """Add ACM cert.""" + if self.acm_certificate_specified: + return cloudfront.ViewerCertificate( + AcmCertificateArn=self.get_variables()['AcmCertificateArn'], + SslSupportMethod='sni-only' + ) + return NoValue + def add_origin_access_identity(self): + # type: () -> cloudfront.CloudFrontOriginAccessIdentity """Add the origin access identity resource to the template. Returns: @@ -293,7 +284,6 @@ def add_origin_access_identity(self): return self.template.add_resource( cloudfront.CloudFrontOriginAccessIdentity( 'OAI', - Condition='CFEnabled', CloudFrontOriginAccessIdentityConfig=cloudfront.CloudFrontOriginAccessIdentityConfig( # noqa pylint: disable=line-too-long Comment='CF access to website' ) @@ -301,6 +291,7 @@ def add_origin_access_identity(self): ) def add_bucket_policy(self, bucket): + # type: (s3.Bucket) -> s3.BucketPolicy """Add a policy to the bucket if CloudFront is disabled. Ensure PublicRead. Keyword Args: @@ -314,7 +305,6 @@ def add_bucket_policy(self, bucket): s3.BucketPolicy( 'BucketPolicy', Bucket=bucket.ref(), - Condition='CFDisabled', PolicyDocument=Policy( Version="2012-10-17", Statement=[ @@ -332,6 +322,7 @@ def add_bucket_policy(self, bucket): ) def add_bucket(self): + # type: () -> s3.Bucket """Add the bucket resource along with an output of it's name / website url. Returns: @@ -341,7 +332,7 @@ def add_bucket(self): bucket = self.template.add_resource( s3.Bucket( 'Bucket', - AccessControl=If('CFEnabled', s3.Private, s3.PublicRead), + AccessControl=(s3.Private if self.cf_enabled else s3.PublicRead), LifecycleConfiguration=s3.LifecycleConfiguration( Rules=[ s3.LifecycleRule( @@ -364,15 +355,18 @@ def add_bucket(self): Description='Name of website bucket', Value=bucket.ref() )) - self.template.add_output(Output( - 'BucketWebsiteURL', - Condition="CFDisabled", - Description='URL of the bucket website', - Value=bucket.get_att('WebsiteURL') - )) + + if not self.cf_enabled: + self.template.add_output(Output( + 'BucketWebsiteURL', + Description='URL of the bucket website', + Value=bucket.get_att('WebsiteURL') + )) + return bucket - def allow_cloudfront_access_on_bucket(self, bucket, oai): + def add_cloudfront_bucket_policy(self, bucket, oai): + # type (s3.Bucket, cloudfront.CloudFrontOriginAccessIdentity) -> s3.BucketPolicy """Given a bucket and oai resource add cloudfront access to the bucket. Keyword Args: @@ -386,28 +380,18 @@ def allow_cloudfront_access_on_bucket(self, bucket, oai): s3.BucketPolicy( 'AllowCFAccess', Bucket=bucket.ref(), - Condition='CFEnabled', PolicyDocument=PolicyDocument( Version='2012-10-17', - Statement=[ - Statement( - Action=[awacs.s3.GetObject], - Effect=Allow, - Principal=Principal( - 'CanonicalUser', - oai.get_att('S3CanonicalUserId') - ), - Resource=[ - Join('', [bucket.get_att('Arn'), - '/*']) - ] - ) - ] + Statement=self._get_cloudfront_bucket_policy_statements( + bucket, + oai + ) ) ) ) def add_index_rewrite_role(self): + # type: () -> iam.Role """Add an index rewrite role to the template. Return: @@ -416,7 +400,6 @@ def add_index_rewrite_role(self): return self.template.add_resource( iam.Role( 'CFDirectoryIndexRewriteRole', - Condition='CFEnabledAndDirectoryIndexSpecified', AssumeRolePolicyDocument=PolicyDocument( Version='2012-10-17', Statement=[ @@ -436,6 +419,7 @@ def add_index_rewrite_role(self): ) def add_cloudfront_directory_index_rewrite(self, role): + # type: (iam.Role) -> awslambda.Function """Add an index CloudFront directory index rewrite lambda function to the template. Keyword Args: @@ -445,34 +429,22 @@ def add_cloudfront_directory_index_rewrite(self, role): dict: The CloudFront directory index rewrite lambda function resource """ variables = self.get_variables() + code_str = '' + path = os.path.join( + os.path.dirname(__file__), + 'templates/cf_directory_index_rewrite.template.js' + ) + with open(path) as file_: + code_str = file_.read().replace( + '{{RewriteDirectoryIndex}}', + variables["RewriteDirectoryIndex"] + ) + return self.template.add_resource( awslambda.Function( 'CFDirectoryIndexRewrite', - Condition='CFEnabledAndDirectoryIndexSpecified', Code=awslambda.Code( - ZipFile=Join( - '', - ["'use strict';\n", - "exports.handler = async function(event, context) {\n", - "\n", - " // Extract the request from the CloudFront event that is sent to Lambda@Edge\n", # noqa pylint: disable=line-too-long - " var request = event.Records[0].cf.request;\n", - " // Extract the URI from the request\n", - " var olduri = request.uri;\n", - " // Match any '/' that occurs at the end of a URI. Replace it with a default index\n", # noqa pylint: disable=line-too-long - " var newuri = olduri.replace(/\\/$/, '\\/", - variables['RewriteDirectoryIndex'].ref, - "');\n", # noqa - " // Log the URI as received by CloudFront and the new URI to be used to fetch from origin\n", # noqa pylint: disable=line-too-long - " console.log(\"Old URI: \" + olduri);\n", - " console.log(\"New URI: \" + newuri);\n", - " // Replace the received URI with the URI that includes the index page\n", # noqa pylint: disable=line-too-long - " request.uri = newuri;\n", - " // Return to CloudFront\n", - " return request;\n", - "\n", - "};\n"] - ) + ZipFile=code_str ), Description='Rewrites CF directory HTTP requests to default page', # noqa Handler='index.handler', @@ -482,6 +454,7 @@ def add_cloudfront_directory_index_rewrite(self, role): ) def add_cloudfront_directory_index_rewrite_version(self, directory_index_rewrite): + # type: (awslambda.Function) -> awslambda.Version """Add a specific version to the directory index rewrite lambda. Keyword Args: @@ -491,29 +464,27 @@ def add_cloudfront_directory_index_rewrite_version(self, directory_index_rewrite dict: The CloudFront directory index rewrite version """ - # Generating a unique resource name here for the Lambda version, so it - # updates automatically if the lambda code changes code_hash = hashlib.md5( - str(directory_index_rewrite.properties['Code'].properties['ZipFile'].to_dict()).encode() # noqa pylint: disable=line-too-long + str(directory_index_rewrite.properties['Code'].properties['ZipFile']).encode() # noqa pylint: disable=line-too-long ).hexdigest() return self.template.add_resource( awslambda.Version( 'CFDirectoryIndexRewriteVer' + code_hash, - Condition='CFEnabledAndDirectoryIndexSpecified', FunctionName=directory_index_rewrite.ref() ) ) def add_cloudfront_distribution( self, - allow_cloudfront_access, + bucket_policy, cloudfront_distribution_options ): + # type: (s3.BucketPolicy, Dict[str, Any]) -> cloudfront.Distribution """Add the CloudFront distribution to the template / output the id and domain name. Keyword Args: - allow_cloudfront_access (dict): Allow bucket access resource + bucket_policy (dict): Bucket policy to allow CloudFront access cloudfront_distribution_options (dict): The distribution options Return: @@ -521,62 +492,42 @@ def add_cloudfront_distribution( """ distribution = self.template.add_resource( - get_cf_distribution_class()( + cloudfront.Distribution( 'CFDistribution', - Condition='CFEnabled', - DependsOn=allow_cloudfront_access.title, - DistributionConfig=get_cf_distro_conf_class()( + DependsOn=bucket_policy.title, + DistributionConfig=cloudfront.DistributionConfig( **cloudfront_distribution_options ) ) ) self.template.add_output(Output( 'CFDistributionId', - Condition='CFEnabled', Description='CloudFront distribution ID', Value=distribution.ref() )) self.template.add_output( Output( 'CFDistributionDomainName', - Condition='CFEnabled', Description='CloudFront distribution domain name', Value=distribution.get_att('DomainName') ) ) return distribution - -def get_cf_distribution_class(): - """Return the correct troposphere CF distribution class.""" - if LooseVersion(troposphere.__version__) == LooseVersion('2.4.0'): - cf_dist = cloudfront.Distribution - cf_dist.props['DistributionConfig'] = (DistributionConfig, True) - return cf_dist - return cloudfront.Distribution - - -def get_cf_distro_conf_class(): - """Return the correct troposphere CF distribution class.""" - if LooseVersion(troposphere.__version__) == LooseVersion('2.4.0'): - return DistributionConfig - return cloudfront.DistributionConfig - - -def get_cf_origin_class(): - """Return the correct Origin class for troposphere.""" - if LooseVersion(troposphere.__version__) == LooseVersion('2.4.0'): - return Origin - return cloudfront.Origin - - -def get_s3_origin_conf_class(): - """Return the correct S3 Origin Config class for troposphere.""" - if LooseVersion(troposphere.__version__) > LooseVersion('2.4.0'): - return cloudfront.S3OriginConfig - if LooseVersion(troposphere.__version__) == LooseVersion('2.4.0'): - return S3OriginConfig - return cloudfront.S3Origin + def _get_cloudfront_bucket_policy_statements(self, bucket, oai): # noqa pylint: disable=no-self-use + return [ + Statement( + Action=[awacs.s3.GetObject], + Effect=Allow, + Principal=Principal( + 'CanonicalUser', + oai.get_att('S3CanonicalUserId') + ), + Resource=[ + Join('', [bucket.get_att('Arn'), '/*']) + ] + ) + ] # Helper section to enable easy blueprint -> template generation diff --git a/runway/blueprints/staticsite/templates/cf_directory_index_rewrite.template.js b/runway/blueprints/staticsite/templates/cf_directory_index_rewrite.template.js new file mode 100644 index 000000000..1487a1543 --- /dev/null +++ b/runway/blueprints/staticsite/templates/cf_directory_index_rewrite.template.js @@ -0,0 +1,20 @@ +'use strict'; + +exports.handler = async function(event, context) { + // Extract the request from the CloudFront event that is sent to Lambda@Edge + var request = event.Records[0].cf.request; + // Extract the URI from the request + var olduri = request.uri; + // Match any '/' that occurs at the end of a URI. + // Replace it with a default index\n", + var newuri = olduri.replace(/\/$/, '/{{RewriteDirectoryIndex}}'); + // Log the URI as received by CloudFront and the + // new URI to be used to fetch from origin + console.log("Old URI: " + olduri); + console.log("New URI: " + newuri); + // Replace the received URI with the URI that includes + // the index page + request.uri = newuri; + // Return to CloudFront + return request; +}; diff --git a/runway/hooks/staticsite/auth_at_edge/__init__.py b/runway/hooks/staticsite/auth_at_edge/__init__.py new file mode 100644 index 000000000..5a8dddc5a --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/__init__.py @@ -0,0 +1 @@ +"""Empty init for python import traversal.""" diff --git a/runway/hooks/staticsite/auth_at_edge/client_updater.py b/runway/hooks/staticsite/auth_at_edge/client_updater.py new file mode 100644 index 000000000..4675260e6 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/client_updater.py @@ -0,0 +1,75 @@ +"""User Pool Client Updater. + +Responsible for updating the User Pool Client with the generated +distribution url + callback url paths. +""" + +import logging +from typing import Any, Dict, Optional # pylint: disable=unused-import + +from runway.cfngin.providers.base import BaseProvider # pylint: disable=unused-import +from runway.cfngin.context import Context # noqa pylint: disable=unused-import +from runway.cfngin.session_cache import get_session + +LOGGER = logging.getLogger(__name__) + + +def update(context, # pylint: disable=unused-argument + provider, + **kwargs + ): # noqa: E124 + # type: (Context, BaseProvider, Optional[Dict[str, Any]]) -> bool + """Update the callback urls for the User Pool Client. + + Required to match the redirect_uri being sent which contains + our distribution and alternate domain names. + + Args: + context (:class:`runway.cfngin.context.Context`): The context + instance. + provider (:class:`runway.cfngin.providers.base.BaseProvider`): + The provider instance + + Keyword Args: + alternate_domains (List[str]): A list of any alternate domains + that need to be listed with the primary distribution domain + redirect_path_sign_in (str): The redirect path after sign in + redirect_path_sign_out (str): The redirect path after sign out + oauth_scopes (List[str]): A list of all available validation + scopes for oauth + """ + session = get_session(provider.region) + cognito_client = session.client('cognito-idp') + + LOGGER.info(kwargs['distribution_domain']) + # Combine alternate domains with main distribution + redirect_domains = kwargs['alternate_domains'] + ['https://' + kwargs['distribution_domain']] + + # Create a list of all domains with their redirect paths + redirect_uris_sign_in = [ + "%s%s" % (domain, kwargs['redirect_path_sign_in']) + for domain in redirect_domains + ] + redirect_uris_sign_out = [ + "%s%s" % (domain, kwargs['redirect_path_sign_out']) + for domain in redirect_domains + ] + # Update the user pool client + try: + cognito_client.update_user_pool_client( + AllowedOAuthScopes=kwargs['oauth_scopes'], + AllowedOAuthFlows=['code'], + SupportedIdentityProviders=kwargs['supported_identity_providers'], + AllowedOAuthFlowsUserPoolClient=True, + ClientId=kwargs['client_id'], + CallbackURLs=redirect_uris_sign_in, + LogoutURLs=redirect_uris_sign_out, + UserPoolId=kwargs['user_pool_id'], + ) + return True + # pylint: disable=broad-except + except Exception as err: + LOGGER.error('Was not able to update the callback urls on ' + 'the user pool client') + LOGGER.error(err) + return False diff --git a/runway/hooks/staticsite/auth_at_edge/domain_updater.py b/runway/hooks/staticsite/auth_at_edge/domain_updater.py new file mode 100644 index 000000000..1f626c800 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/domain_updater.py @@ -0,0 +1,82 @@ +"""User Pool Client Domain Updater.""" +import logging +from typing import Any, Dict, Optional, Union # pylint: disable=unused-import + +from runway.cfngin.session_cache import get_session +from runway.cfngin.providers.base import BaseProvider # pylint: disable=unused-import +from runway.cfngin.context import Context # pylint: disable=unused-import + +LOGGER = logging.getLogger(__name__) + + +# pylint: disable=unused-argument +def update(context, # type: Context # pylint: disable=unused-import + provider, # type: BaseProvider + **kwargs # type: Optional[Dict[str, Any]] + ): # noqa: E124 + # type: (...) -> Union[Dict[str, Any], bool] + """Retrieve/Update the domain name of the specified client. + + A domain name is required in order to make authorization and token + requests. This prehook ensures we have one available, and if not + we create one based on the user pool and client ids. + + Args: + context (:class:`runway.cfngin.context.Context`): The context + instance. + provider (:class:`runway.cfngin.providers.base.BaseProvider`): + The provider instance + + Keyword Args: + user_pool_id (str): The ID of the Cognito User Pool + client_id (str): The ID of the Cognito User Pool Client + """ + session = get_session(provider.region) + cognito_client = session.client('cognito-idp') + + context_dict = {} + + user_pool_id = kwargs['user_pool_id'] + client_id = kwargs['client_id'] + user_pool = cognito_client.describe_user_pool(UserPoolId=user_pool_id).get('UserPool') + (user_pool_region, user_pool_hash) = user_pool_id.split('_') + + domain_prefix = user_pool.get('CustomDomain') or user_pool.get('Domain') + + # Return early if we already have a domain + if domain_prefix: + context_dict['domain'] = get_user_pool_domain( + domain_prefix, + user_pool_region + ) + return context_dict + + try: + domain_prefix = ('%s-%s' % (user_pool_hash, client_id)).lower() + + cognito_client.create_user_pool_domain( + Domain=domain_prefix, + UserPoolId=user_pool_id + ) + context_dict['domain'] = get_user_pool_domain( + domain_prefix, + user_pool_region + ) + return context_dict + except Exception as err: # pylint: disable=broad-except + LOGGER.error( + 'Could not update user pool domain for user pool id %s.', + user_pool_id + ) + LOGGER.error(err) + return False + + +def get_user_pool_domain(prefix, region): + """Return a user pool domain name based on the prefix received and region. + + Args: + prefix (str): The domain prefix for the domain + region (str): The region in which the pool resides + """ + return '%s.auth.%s.amazoncognito.com' % (prefix, region) diff --git a/runway/hooks/staticsite/auth_at_edge/lambda_config.py b/runway/hooks/staticsite/auth_at_edge/lambda_config.py new file mode 100644 index 000000000..7cd044afa --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/lambda_config.py @@ -0,0 +1,141 @@ +"""CFNgin prehook responsible for creation of Lambda@Edge functions.""" + +import logging +import os +import re +import tempfile +from distutils.dir_util import copy_tree # pylint: disable=import-error, no-name-in-module +from tempfile import mkstemp +from typing import Any, Dict, Optional # pylint: disable=unused-import + +from runway.cfngin.hooks import aws_lambda + +from runway.cfngin.providers.base import BaseProvider # pylint: disable=unused-import + +# The functions associated with Auth@Edge +FUNCTIONS = [ + 'check_auth', + 'refresh_auth', + 'parse_auth', + 'sign_out', + 'http_headers' +] + + +LOGGER = logging.getLogger(__name__) + + +def write(context, # type: context.Context + provider, # type: BaseProvider + **kwargs # type: Optional[Dict[str, Any]] + ): # noqa: E124 + # type: (...) -> Dict[str, Any] + """Writes/Uploads the configured lambdas for Auth@Edge. + + Lambda@Edge does not have the ability to allow Environment variables + at the time of this writing. In order to configure our lambdas with + dynamic variables we first will go through and update a "shared" template + with all of the configuration elements and add that to a temporary + folder along with each of the individual Lambda@Edge functions. This + temporary folder is then used with the CFNgin awsLambda hook to build + the functions. + + Args: + context (cfngin.Context): The CFNgin context + provider (cfngin.Provider): The CFNgin provider + + Keyword Args: + client_id (str): The ID of the Cognito User Pool Client + cookie_settings (dict): The settings for our customized cookies + http_headers (dict): The additional headers added to our requests + oauth_scopes (List[str]): The validation scopes for our OAuth requests + redirect_path_auth_refresh (str): The URL path for authorization refresh + redirect (Correlates to the refresh auth lambda) + redirect_path_sign_in (str): The URL path to be redirected to after + sign in (Correlates to the parse auth lambda) + redirect_path_sign_out (str): The URL path to be redirected to after + sign out (Correlates to the root to be asked to resignin) + user_pool_id (str): The ID of the Cognito User Pool + """ + cognito_domain = context.hook_data['aae_domain_updater'].get('domain') + config = { + 'client_id': kwargs['client_id'], + 'cognito_auth_domain': cognito_domain, + 'cookie_settings': kwargs['cookie_settings'], + 'http_headers': kwargs['http_headers'], + 'oauth_scopes': kwargs['oauth_scopes'], + 'redirect_path_auth_refresh': kwargs['redirect_path_refresh'], + 'redirect_path_sign_in': kwargs['redirect_path_sign_in'], + 'redirect_path_sign_out': kwargs['redirect_path_sign_out'], + 'user_pool_id': kwargs['user_pool_id'], + } + LOGGER.info('LAMBDA_CONFIG') + LOGGER.info(config) + + # Shared file that contains the method called for configuration data + path = os.path.join( + os.path.dirname(__file__), + 'templates/shared.py' + ) + context_dict = {} + + with open(path) as file_: + # Dynamically replace our configuration values + # in the shared.py template file with actual + # calculated values + shared = re.sub( + r'{.+?(})$', + str(config), + file_.read(), + 1, + flags=re.DOTALL | re.MULTILINE + ) + + filedir, temppath = mkstemp() + + # Save the file to a temp path + with open(temppath, 'w') as tmp: + tmp.write(shared) + config = temppath + os.close(filedir) + + # Get all of the different Auth@Edge functions + for handler in FUNCTIONS: + # Create a temporary folder + dirpath = tempfile.mkdtemp() + + # Copy the template code for the specific Lambda function + # to the temporary folder + copy_tree( + os.path.join( + os.path.dirname(__file__), + 'templates/%s' % handler + ), + dirpath + ) + + # Save our dynamic configuration shared file to the + # temporary folder + with open(config) as shared: + raw = shared.read() + filename = 'shared.py' + with open('%s/%s' % (dirpath, filename), 'wb') as newfile: + newfile.write(raw.encode()) + + # Upload our temporary folder to our S3 bucket for + # Lambda use + lamb = aws_lambda.upload_lambda_functions( + context, + provider, + bucket=kwargs['bucket'], + functions={ + handler: { + 'path': dirpath, + } + } + ) + + # Add the lambda code reference to our context_dict + context_dict.update(lamb) + + return context_dict diff --git a/runway/hooks/staticsite/auth_at_edge/templates/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/__init__.py new file mode 100644 index 000000000..5a8dddc5a --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/__init__.py @@ -0,0 +1 @@ +"""Empty init for python import traversal.""" diff --git a/runway/hooks/staticsite/auth_at_edge/templates/check_auth/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/__init__.py new file mode 100644 index 000000000..bb53e4c8f --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/__init__.py @@ -0,0 +1,248 @@ + +"""Check the authorization information passed along via the cookie headers. + +When the information is not present or an error occurs due to verification a new +request to Cognito will be made to authorize the user. The user will be taken +to the Cognito login page to enter their information, and so long as it is valid, +the information will be passed to the parsing agent. + +If a refresh token exists and it has expired (after 1 hour) do an automatic refresh +of the credentials by redirecting the user to the refresh agent. +""" +import base64 +import datetime +import hashlib +import json +import logging +import secrets # pylint: disable=import-error +import re +from urllib.parse import quote_plus, urlencode # noqa pylint: disable=no-name-in-module, import-error + +from jose import jwt # pylint: disable=import-error + +from jwks_rsa.client import JwksClient # pylint: disable=import-error +from shared import decode_token, extract_and_parse_cookies, get_config # noqa pylint: disable=import-error + +LOGGER = logging.getLogger(__file__) + +SECRET_ALLOWED_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" +NONCE_LENGTH = 16 +PKCE_LENGTH = 43 +CONFIG = get_config() + + +def handler(event, _context): + """Handle the request passed in. + + Keyword Args: + event (Dict[str, Any]): The Lambda Event + """ + request = event['Records'][0]['cf']['request'] + domain_name = request['headers']['host'][0]['value'] + querystring = request.get('querystring') + request_query_string = ( + "?%s" % querystring + ) if querystring else "" + requested_uri = '%s%s' % (request['uri'], request_query_string) + nonce = generate_nonce() + + try: + # Extract the cookies received from Cognito + extracted = extract_and_parse_cookies( + request['headers'], + CONFIG['client_id'] + ) + token_user_name = extracted.get('tokenUserName') + id_token = extracted.get('idToken') + refresh_token = extracted.get('refreshToken') + + # If the token user name or id token are missing then we need + # new credentials + if not token_user_name or not id_token: + msg = "No valid credentials present in cookies" + LOGGER.error(msg) + raise Exception(msg) + + # Get the expiration date from the id token. + decoded_token = decode_token(id_token) + expiration = decoded_token.get('exp') + exp_date = datetime.datetime.fromtimestamp(expiration) + now = datetime.datetime.now() + + # If we have a refresh token and the expiration date has passed + # then forward the user to the refresh agent + if now > exp_date and refresh_token: + headers = { + # Redirect the user to the refresh agent + 'location': [ + { + 'key': 'location', + 'value': 'https://%s%s?%s' % ( + domain_name, + CONFIG.get('redirect_path_auth_refresh'), + urlencode({ + 'requestedUri': requested_uri, + 'nonce': nonce + }) + ) + } + ], + 'set-cookie': [ + # Set the Nonce Cookie + { + 'key': 'set-cookie', + 'value': 'spa-auth-edge-nonce=%s; %s' % ( + quote_plus(nonce), + CONFIG.get('cookie_settings').get('nonce') + ) + } + ] + } + # Add all CloudFrontHeaders + headers.update(CONFIG.get('cloud_front_headers')) + return { + 'status': '307', + 'statusDescription': 'Temporary Redirect', + 'headers': headers + } + + # Validate the token information against the Cognito JWKS + validate_jwt( + id_token, + CONFIG.get('token_jwks_uri'), + CONFIG.get('token_issuer'), + CONFIG.get('client_id')) + + return request + # We need new authorization. Get the user over to Cognito + except Exception: # noqa pylint: disable=broad-except + pkce, pkce_hash = generate_pkce_verifier() + payload = { + 'redirect_uri': 'https://%s%s' % (domain_name, CONFIG['redirect_path_sign_in']), + 'response_type': 'code', + 'client_id': CONFIG["client_id"], + 'state': base64.urlsafe_b64encode( + bytes(json.dumps({ + "nonce": nonce, + "requestedUri": requested_uri + }).encode()) + ), + 'scope': " ".join(CONFIG['oauth_scopes']), + 'code_challenge_method': "S256", + 'code_challenge': pkce_hash + } + login_query_string = urlencode(payload, quote_via=quote_plus) + headers = CONFIG.get('cloud_front_headers') + headers['location'] = [ + { + 'key': 'location', + 'value': 'https://%s/oauth2/authorize?%s' % ( + CONFIG['cognito_auth_domain'], + login_query_string + ) + } + ] + headers['set-cookie'] = [ + # Set Nonce Cookie + { + 'key': 'set-cookie', + 'value': 'spa-auth-edge-nonce=%s; %s' % ( + quote_plus(nonce), + CONFIG.get('cookie_settings', {}).get('nonce') + ) + }, + # Set PKCE Cookie + { + 'key': 'set-cookie', + 'value': 'spa-auth-edge-pkce=%s; %s' % ( + quote_plus(pkce), + CONFIG.get('cookie_settings', {}).get('nonce') + ) + }, + ] + + # Redirect user to the Cognito Login + return { + 'status': '307', + 'statusDescription': 'Temporary Redirect', + 'headers': headers + } + + +def generate_pkce_verifier(): + """Generate the PKCE verification code.""" + pkce = random_key(PKCE_LENGTH) + pkce_hash = hashlib.sha256() + pkce_hash.update(pkce.encode('UTF-8')) + pkce_hash = pkce_hash.digest() + pkce_hash_b64 = base64.b64encode(pkce_hash) + decoded_pkce = pkce_hash_b64.decode("UTF-8") + decoded_pkce = re.sub(r'\=', '', decoded_pkce) + decoded_pkce = re.sub(r'\+', '-', decoded_pkce) + decoded_pkce = re.sub(r'\/', '_', decoded_pkce) + + return [pkce, decoded_pkce] + + +def generate_nonce(): + """Generate a random Nonce token.""" + return random_key(NONCE_LENGTH) + + +def random_key(length=15): + """Generate a random key of specified length from the allowed secret characters. + + Keyword Args: + length (int): The length of the random key + """ + return ''.join(secrets.choice(SECRET_ALLOWED_CHARS) for _ in range(length)) + + +def validate_jwt(jwt_token, + jwks_uri, + issuer, + audience + ): # noqa: E124 + """Validate the JWT token against the Cognito JWKs. + + Keyword Args: + jwt_token (str): The JSON Web Token to validate + jwks_uri (str): The URI in which to retrieve the JSON Web Keys + issuer (str): Issuer of the JWT + audience (str): Audience of the JWT + """ + token_headers = jwt.get_unverified_header(jwt_token) + if not token_headers: + raise Exception('Cannot Parse JWT Token Headers') + + kid = token_headers.get('kid') + jwk = get_signing_key(jwks_uri, kid) + + return jwt.decode( + jwt_token, + jwk, + algorithms=['RS256'], + audience=audience, + issuer=issuer, + options={'verify_at_hash': False}) + + +def is_rsa_signing_key(key): + """Verify if the key specified is an RSA Public Key. + + Keyword Args: + key (Dict): The key to filter + """ + return "rsaPublicKey" in key + + +def get_signing_key(jwks_uri, kid): + """Retrieve the signing keys from the JWKS uri that match the key id specified. + + Keyword Args: + jwks_uri (str): The URI in which to retrieve the JWKs + kid (str): Key ID of the signing key we are looking for + """ + client = JwksClient({'jwks_uri': jwks_uri}) + jwk = client.get_signing_key(kid) + return jwk.get('rsaPublicKey') if is_rsa_signing_key(jwk) else jwk.get('publicKey') diff --git a/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/client.py b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/client.py new file mode 100644 index 000000000..86c04acb7 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/client.py @@ -0,0 +1,114 @@ +"""Client for handling retrieval of JWKS signing keys.""" +import json +import logging +import urllib + +# pylint: disable=relative-beyond-top-level +from .utils import rsa_public_key_to_pem + + +def is_signing_key(key): + """Filter to determine if this is a signing key. + + Keyword Args: + key (Dict[str, str]): The key + """ + if key.get('kty', '') != 'RSA': + return False + if not key.get('kid', None): + return False + if key.get('use', '') != 'sig': + return False + return key.get('x5c') or (key.get('n') and key.get('e')) + + +class JwksClient(object): + """Client responsible for retrieval of JWKS signing keys.""" + + def __init__(self, options=None): + """Initialize. + + Keyword Args: + options (Optional[Dict[str, str]]): Options for the client + """ + self.options = options + self.logger = logging.getLogger('__file__') + + def get_keys(self): + """Retrieve the keys from the JWKS endpoint.""" + self.logger.info('Fetching keys from %s', self.options.get('jwks_uri')) + + try: + request = urllib.request.urlopen(self.options.get('jwks_uri')) # noqa pylint: disable=no-member + data = json.loads( + request.read().decode( + request.info().get_param('charset') or 'utf-8' + ) + ) + keys = data['keys'] + self.logger.info("Keys: %s", keys) + return keys + # pylint: disable=broad-except + except Exception as err: + self.logger.info('Failure: ConnectionError') + self.logger.info(err) + return {} + + def get_signing_key(self, kid): + """Given a specific key id (kid) retrieve the signing key associated. + + Keyword Args: + kid (str): The key id of the signing key + """ + self.logger.info('Fetching signing key for %s', kid) + + keys = self.get_signing_keys() + try: + key = next(x for x in keys if x.get('kid') == kid) + return key + except StopIteration: + raise Exception('Was not able to locate a key with kid %s' % kid) + + def get_signing_keys(self): + """Given a set of keys find all that are signing keys.""" + keys = self.get_keys() + + if not keys: + raise Exception('The JWKS endpoint did not contain any keys') + + jwks = [] + for key in filter(is_signing_key, keys): + jwks.append(self.create_jwk(key)) + + if not jwks: + raise Exception("The JWKS endpoint did not contain any signing keys") + + self.logger.info("Signing Keys: %s", jwks) + return jwks + + def create_jwk(self, key): + """Create the JSON Web Key. + + Keyword Args: + key (dict): The Retrieved signing key + """ + jwk = { + 'kid': key.get('kid'), + 'nbf': key.get('nbf') + } + + if key.get('x5c'): + # @TODO: Support certificate chains. Review library here: + # https://github.com/auth0/node-jwks-rsa/blob/master/src/JwksClient.js#L87 + self.logger.info('X5C') + else: + try: + jwk['rsaPublicKey'] = rsa_public_key_to_pem( + key.get('n'), + key.get('e') + ) + # pylint: disable=broad-except + except Exception as err: + self.logger.error(err) + jwk['rsaPublicKey'] = None + return jwk diff --git a/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/utils.py b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/utils.py new file mode 100644 index 000000000..6dfa6e578 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/jwks_rsa/utils.py @@ -0,0 +1,76 @@ +"""Utility functions for JWKS RSA library.""" +import base64 +import codecs +import logging +import re + +LOGGER = logging.getLogger(__name__) + + +def prepad_signed(hex_str): + """Given a hexadecimal string prepad with 00 if not within range. + + Keyword Args: + hex_str (string): The hexadecimal string + """ + msb = hex_str[0] + if (msb < '0' or msb > '7'): + return '00%s' % hex_str + return hex_str + + +def to_hex(number): + """Convert an integer to appropriate hex. + + Keyword Args: + number (int): The number to convert + """ + n_str = format(int(number), 'x') + if len(n_str) % 2: + return '0%s' % n_str + return n_str + + +def encode_length_hex(number): + """Encode the length value to a hexadecimal. + + Keyword Args: + number (int): The number to convert + """ + if number <= 127: + return to_hex(number) + n_hex = to_hex(number) + length = int(128 + len(n_hex) / 2) + return to_hex(length) + n_hex + + +def rsa_public_key_to_pem(modulus_b64, exponent_b64): + """Given an modulus and exponent convert an RSA public key to public PEM. + + Keyword Args: + modulus_b64 (string): Base64 encoded modulus + exponent_b64 (string): Base64 encoded exponent + """ + modulus = base64.urlsafe_b64decode(modulus_b64 + "===") + exponent = base64.urlsafe_b64decode(exponent_b64 + "===") + modulus_hex = prepad_signed(modulus.hex()) + exponent_hex = prepad_signed(exponent.hex()) + mod_len = int(len(modulus_hex) / 2) + exp_len = int(len(exponent_hex) / 2) + + encoded_mod_len = encode_length_hex(mod_len) + encoded_exp_len = encode_length_hex(exp_len) + + encoded_pub_key = '30' + encoded_pub_key += encode_length_hex( + mod_len + exp_len + len(encoded_mod_len) / 2 + len(encoded_exp_len) / 2 + 2 + ) + encoded_pub_key += '02' + encoded_mod_len + modulus_hex + encoded_pub_key += '02' + encoded_exp_len + exponent_hex + + der = base64.b64encode(codecs.decode(encoded_pub_key, 'hex')) + + pem = '-----BEGIN RSA PUBLIC KEY-----\n' + pem += '\n'.join(re.findall('.{1,64}', der.decode())) + pem += '\n-----END RSA PUBLIC KEY-----\n' + return pem diff --git a/runway/hooks/staticsite/auth_at_edge/templates/check_auth/requirements.txt b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/requirements.txt new file mode 100644 index 000000000..87759c8c2 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/check_auth/requirements.txt @@ -0,0 +1,7 @@ +-i https://pypi.org/simple +ecdsa==0.15 +pyasn1==0.4.8 +pyjwt==1.7.1 +python-jose==3.1.0 +rsa==4.0 +six==1.14.0 diff --git a/runway/hooks/staticsite/auth_at_edge/templates/http_headers/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/http_headers/__init__.py new file mode 100644 index 000000000..52ccf4134 --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/http_headers/__init__.py @@ -0,0 +1,18 @@ +"""Add all configured headers (CloudFront compatable) to origin response.""" +from shared import as_cloud_front_headers, get_config # pylint: disable=import-error + +CONFIG = get_config() + + +def handler(event, _context): + """Handle adding the headers to the origin response. + + Keyword Args: + event (Any): The Lambda Event + """ + headers = CONFIG.get('http_headers') + # Format to be CloudFront compatable + configured_headers = as_cloud_front_headers(headers) + response = event['Records'][0]['cf']['response'] + response['headers'].update(configured_headers) + return response diff --git a/runway/hooks/staticsite/auth_at_edge/templates/parse_auth/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/parse_auth/__init__.py new file mode 100644 index 000000000..8e9f5e6cd --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/parse_auth/__init__.py @@ -0,0 +1,134 @@ +"""Parse the given Cognito/Local authorization information. + +Ensure the parsed Cognito and Local authorization information from the retrieved +query string parameter and cookie headers matches our expectations. If it doesn't +then inform the user of a bad request, otherwise retrieve the Cognito tokens to +add to the cookie headers. +""" + +import json +import logging +import traceback +import base64 +from urllib.parse import parse_qs # noqa pylint: disable=no-name-in-module,import-error + +from shared import (create_error_html, extract_and_parse_cookies, get_config, # noqa pylint: disable=import-error + get_cookie_headers, http_post_with_retry) + +LOGGER = logging.getLogger(__name__) +CONFIG = get_config() + + +def handler(event, _context): + """Handle the authorization parsing. + + Keyword Args: + event (Any): The Lambda Event + """ + request = event['Records'][0]['cf']['request'] + domain_name = request['headers']['host'][0]['value'] + redirected_from_uri = 'https://%s' % domain_name + + # Attempt to parse the request and retrieve authorization + # tokens to integrate with our header cookies + try: + qsp = parse_qs(request.get('querystring')) + # Authorization code given by Cognito + code = qsp.get('code') + # The requested URI and current nonce information + state = json.loads( + base64.urlsafe_b64decode(qsp.get('state')[0]).decode() + ) + + # Missing required components + if not code or not state: + msg = 'Invalid query string. ' + \ + 'Your query string should include parameters "state" and "code"' + LOGGER.info(msg) + raise Exception(msg) + + current_nonce = state.get('nonce') + requested_uri = state.get('requestedUri') + redirected_from_uri = requested_uri or "/" + # Get all the cookies from the headers + cookies = extract_and_parse_cookies( + request.get('headers'), + CONFIG.get('client_id') + ) + # Retrieve the original nonce value as well as our PKCE code + original_nonce = cookies.get('nonce') + pkce = cookies.get('pkce') + + # If we're missing one of the nonces, or they don't match, cause an error + if not current_nonce or not original_nonce or current_nonce != original_nonce: + # No original nonce? CSRF violation + if not original_nonce: + msg = "Your browser didn't send the nonce cookie along, " + \ + "but it is required for security (prevent CSRF)" + LOGGER.error(msg) + raise Exception(msg) + # Nonce's don't match + msg = "Nonce Mismatch" + LOGGER.error(msg) + raise Exception(msg) + + payload = { + 'grant_type': 'authorization_code', + 'client_id': CONFIG.get('client_id'), + 'redirect_uri': 'https://%s%s' % (domain_name, CONFIG.get('redirect_path_sign_in')), + 'code': code[0], + 'code_verifier': pkce + } + + # Request tokens from our Cognito Authorization Domain + tokens = http_post_with_retry( + ('https://%s/oauth2/token' % CONFIG.get('cognito_auth_domain')), + payload, + {"Content-Type": "application/x-www-form-urlencoded"} + ) + + if not tokens: + raise Exception('Was not able to obtain tokens from Cognito') + + headers = { + 'location': [ + { + 'key': 'location', + 'value': redirected_from_uri + } + ], + 'set-cookie': get_cookie_headers( + CONFIG.get("client_id"), + CONFIG.get("oauth_scopes"), + tokens, + domain_name, + CONFIG.get('cookie_settings') + ) + } + headers.update(CONFIG.get('cloud_front_headers')) + # Redirect user to the originally requested uri with the + # token header cookies + response = { + 'status': '307', + 'statusDescription': 'Temporary Redirect', + 'headers': headers + } + return response + except Exception as err: # pylint: disable=broad-except + LOGGER.error(err) + LOGGER.error(traceback.print_exc()) + + headers = CONFIG.get('cloud_front_headers') + headers['content-type'] = [ + { + 'key': 'Content-Type', + 'value': 'text/html; charset=UTF-8' + } + ] + + # Inform user of bad request and reason + return { + 'body': create_error_html("Bad Request", err, redirected_from_uri), + 'status': '400', + 'headers': headers + } diff --git a/runway/hooks/staticsite/auth_at_edge/templates/refresh_auth/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/refresh_auth/__init__.py new file mode 100644 index 000000000..9883e048b --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/refresh_auth/__init__.py @@ -0,0 +1,141 @@ +"""Refresh the authorization token for new credentials.""" + +import logging +import traceback +from urllib.parse import parse_qs # noqa pylint: disable=no-name-in-module, import-error + +from shared import (create_error_html, extract_and_parse_cookies, get_config, # noqa pylint: disable=import-error + get_cookie_headers, http_post_with_retry) + +LOGGER = logging.getLogger(__name__) +CONFIG = get_config() + + +def handler(event, _context): + """Handle the authorization refresh. + + Keyword Args: + event: The Lambda Event + """ + request = event['Records'][0]['cf']['request'] + domain_name = request['headers']['host'][0]['value'] + redirected_from_uri = 'https://%s' % domain_name + + try: + parsed_qs = parse_qs(request.get('querystring')) + requested_uri = parsed_qs.get('requestedUri')[0] + current_nonce = parsed_qs.get('nonce')[0] + # Add the requested uri path to the main + redirected_from_uri += requested_uri or "" + + cookies = extract_and_parse_cookies( + request.get('headers'), + CONFIG.get('client_id') + ) + + tokens = { + 'id_token': cookies.get('idToken'), + 'access_token': cookies.get('accessToken'), + 'refresh_token': cookies.get('refreshToken') + } + + validate_refresh_request( + current_nonce, + cookies.get('nonce'), + tokens + ) + + try: + # Request new tokens based on the refresh_token + body = { + 'grant_type': 'refresh_token', + 'client_id': CONFIG.get('client_id'), + 'refresh_token': tokens.get('refresh_token') + } + res = http_post_with_retry( + ('https://%s/oauth2/token' % CONFIG.get('cognito_auth_domain')), + body, + {'Content-Type': 'application/x-www-form-urlencoded'} + ) + tokens['id_token'] = res.get('id_token') + tokens['access_token'] = res.get('access_token') + except Exception as err: # pylint: disable=broad-except + LOGGER.error(err) + # Otherwise clear the refresh token + tokens['refresh_token'] = "" + + headers = { + 'location': [ + { + 'key': 'location', + 'value': redirected_from_uri + } + ], + 'set-cookie': get_cookie_headers( + CONFIG.get('client_id'), + CONFIG.get('oauth_scopes'), + tokens, + domain_name, + CONFIG.get('cookie_settings') + ) + } + headers.update(CONFIG.get('cloud_front_headers')) + + # Redirect the user back to their requested uri + # with new tokens at hand + return { + 'status': '307', + 'statusDescription': 'Temporary Redirect', + 'headers': headers + } + + # Send a basic html error response and inform the user + # why refresh was unsuccessful + except Exception as err: # pylint: disable=broad-except + LOGGER.info(err) + LOGGER.info(traceback.print_exc()) + + headers = { + "content-type": [ + { + "key": "Content-Type", + "value": "text/html; charset=UTF-8" + } + ] + } + headers.update(CONFIG.get('cloud_front_headers')) + + return { + 'body': create_error_html("Bad Request", err, redirected_from_uri), + 'status': '400', + 'headers': headers + } + + +def validate_refresh_request(current_nonce, + original_nonce, + tokens): + """Validate that nonce and tokens are present. + + Keyword Args: + current_nonce (str): The current nonce code + original_nonce (str): The original nonce code + tokens (Dict[str, str]): A dictionary of all the token_types + and their corresponding token values (id, auth, refresh) + """ + if not original_nonce: + msg = "Your browser didn't send the nonce cookie along, " + \ + "but it is required for security (prevent CSRF)." + LOGGER.error(msg) + raise Exception(msg) + + if current_nonce != original_nonce: + msg = "Nonce mismatch" + LOGGER.error(msg) + raise Exception(msg) + + for token_type, token in tokens.items(): + if not token: + msg = "Missing %s" % token_type + LOGGER.error(msg) + raise Exception(msg) diff --git a/runway/hooks/staticsite/auth_at_edge/templates/shared.py b/runway/hooks/staticsite/auth_at_edge/templates/shared.py new file mode 100644 index 000000000..5bf13716c --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/shared.py @@ -0,0 +1,354 @@ +"""Shared functionality for the Auth@Edge Lambda suite.""" + +import base64 +import json +import logging +import re +import time +import traceback +from datetime import datetime +from random import random +from urllib import request # pylint: disable=no-name-in-module +from urllib.parse import urlencode # pylint: disable=no-name-in-module,import-error + +LOGGER = logging.getLogger(__name__) + + +def get_config(): + """Retrieve the configuration variables for the Auth@Edge suite. + + Lambda@Edge restricts the ability to use environment variables. This configuration + object is generated with hard coded values via Runway. + """ + # This configuration will be replaced with dynamic values + # via the lambda_config.py Runway hook. Please review + # that file for more information. + config = { + 'client_id': 'tbd', + 'cognito_auth_domain': 'tbd', + 'cookie_settings': {}, + 'http_headers': {}, + 'oauth_scopes': [], + 'redirect_path_auth_refresh': 'tbd', + 'redirect_path_sign_in': 'tbd', + 'redirect_path_sign_out': 'tbd', + 'user_pool_id': 'tbd', + } + + user_pool_region = 'us-east-1' + region_match = re.match( + r"^(\\S+?)_\\S+$", + config['user_pool_id'] + ) + if region_match: + user_pool_region = region_match.groups()[0] + + config['cloud_front_headers'] = as_cloud_front_headers( + config['http_headers'] + ) + config['token_issuer'] = 'https://cognito-idp.%s.amazonaws.com/%s' % ( + user_pool_region, + config['user_pool_id'] + ) + config['token_jwks_uri'] = '%s/.well-known/jwks.json' % config['token_issuer'] + return config + + +def as_cloud_front_headers(headers): + """Convert a series of headers to CloudFront compliant ones. + + Keyword Args: + headers (Dict[str, str]): The request/response headers in + dictionary format. + """ + res = {} + for key, value in headers.items(): + res[key.lower()] = [{'key': key, 'value': value}] + return res + + +def extract_and_parse_cookies(headers, client_id): + """Extract and parse the Cognito cookies from the headers. + + Keyword Args: + headers (Dict[str, str]): The request/response headers in + dictionary format. + client_id (str): The Cognito UserPool Client ID + """ + cookies = extract_cookies_from_headers(headers) + + if not cookies: + return {} + + key_prefix = 'CognitoIdentityServiceProvider.%s' % client_id + last_user_key = '%s.LastAuthUser' % key_prefix + token_user_name = cookies.get(last_user_key, '') + + scope_key = '%s.%s.tokenScopesString' % (key_prefix, token_user_name) + scopes = cookies.get(scope_key, '') + + id_token_key = '%s.%s.idToken' % (key_prefix, token_user_name) + id_token = cookies.get(id_token_key, '') + + access_token_key = '%s.%s.accessToken' % (key_prefix, token_user_name) + access_token = cookies.get(access_token_key, '') + + refresh_token_key = '%s.%s.refreshToken' % (key_prefix, token_user_name) + refresh_token = cookies.get(refresh_token_key, '') + + return { + 'tokenUserName': token_user_name, + 'idToken': id_token, + 'accessToken': access_token, + 'refreshToken': refresh_token, + 'scopes': scopes, + 'nonce': cookies.get('spa-auth-edge-nonce', ''), + 'pkce': cookies.get('spa-auth-edge-pkce', '') + } + + +def extract_cookies_from_headers(headers): + """Extract all cookies from the response headers. + + Keyword Args: + headers (Dict[str, Dict[str, str]]): The request/response headers in + dictionary format. + """ + if "cookie" not in headers: + return {} + + cookies = {} + for header in headers["cookie"]: + split = header.get('value', '').split(';') + for part in split: + seq = part.split("=") + key, value = seq[0], seq[1:] + cookies[key.strip()] = '='.join(value).strip() + + return cookies + + +def decode_token(jwt): + """Decode the JWT and load it's respective parts as JSON. + + Keyword Args: + jwt (str): The JSON Web Token to parse/decode + """ + token_body = jwt.split('.')[1] + decodable_token_body = re.sub(r'\-', '+', token_body) + decodable_token_body = re.sub(r'\_', '/', token_body) + decodable_token_body = base64.b64decode(decodable_token_body + "===") + return json.loads(decodable_token_body.decode("utf8").replace("'", '"')) + + +def with_cookie_domain(distribution_domain_name, cookie_settings): + """Check to see if the cookie has a domain, if not then add an Amplify JS compatible one. + + Keyword Args: + distribution_domain_name (str): The domain name of the + CloudFront distribution + cookie_settings (str): The other settings for the cookie + """ + try: + cookie_settings.lower().index('domain') + return cookie_settings + except ValueError: + # Add leading dot for compatibility with Amplify (js-cookie) + return '%s; Domain=.%s' % (cookie_settings, distribution_domain_name) + + +def get_cookie_headers(client_id, + oauth_scopes, + tokens, + domain_name, + cookie_settings, + expire_all_tokens=False): + """Retrieve all cookie headers for our request. + + Return as CloudFront formatted headers. + + Keyword Args: + client_id (str): The Cognito UserPool Client ID + oauth_scopes (List): The scopes for oauth validation + tokens (Dict[str, str]): The tokens received from + the Cognito Request (id, access, refresh) + domain_name (str): The Domain name the cookies are + to be associated with + cookie_settings (Dict[str, str]): The various settings + that we would like for the various tokens + expire_all_tokens (Optional[bool]): Whether to expire + all Cognito tokens + """ + decoded_id_token = decode_token(tokens['id_token']) + token_user_name = decoded_id_token.get('cognito:username') + key_prefix = 'CognitoIdentityServiceProvider.%s' % client_id + key_and_user_prefix = '%s.%s' % (key_prefix, token_user_name) + id_token_key = '%s.idToken' % key_and_user_prefix + access_token_key = '%s.accessToken' % key_and_user_prefix + refresh_token_key = '%s.refreshToken' % key_and_user_prefix + last_user_key = '%s.LastAuthUser' % key_prefix + scope_key = '%s.tokenScopesString' % key_and_user_prefix + scopes_string = ' '.join(oauth_scopes) + user_data_key = '%s.userData' % key_and_user_prefix + user_data = { + 'UserAttributes': [ + { + 'Name': "sub", + 'Value': decoded_id_token.get('sub') + }, + { + 'Name': 'email', + 'Value': decoded_id_token.get('email') + } + ], + 'Username': token_user_name + } + + cookies = { + id_token_key: '%s; %s' % ( + tokens.get('id_token'), + with_cookie_domain( + domain_name, + cookie_settings.get('idToken') + ) + ), + access_token_key: '%s; %s' % ( + tokens.get('access_token'), + with_cookie_domain( + domain_name, + cookie_settings.get('accessToken') + ) + ), + refresh_token_key: '%s; %s' % ( + tokens.get('refresh_token'), + with_cookie_domain( + domain_name, + cookie_settings.get('refreshToken') + ) + ), + last_user_key: '%s; %s' % ( + token_user_name, + with_cookie_domain( + domain_name, + cookie_settings.get('idToken') + ) + ), + scope_key: '%s; %s' % ( + scopes_string, + with_cookie_domain( + domain_name, + cookie_settings.get('accessToken') + ) + ), + user_data_key: '%s; %s' % ( + urlencode(user_data), + with_cookie_domain( + domain_name, + cookie_settings.get('idToken') + ) + ), + 'amplify-signin-with-hostedUI': 'true; %s' % ( + with_cookie_domain( + domain_name, + cookie_settings.get('accessToken') + ) + ) + } + + if expire_all_tokens: + for key in cookies: + cookies[key] = expire_cookie(cookies[key]) + elif not tokens.get('refresh_token'): + cookies[refresh_token_key] = expire_cookie(cookies[refresh_token_key]) + + cloud_front_headers = [] + for key, val in cookies.items(): + cloud_front_headers.append({'key': 'set-cookie', 'value': '%s=%s' % (key, val)}) + + return cloud_front_headers + + +def expire_cookie_filter(cookie): + """Filter to determine if a cookie starts with 'max-age' or 'expires'. + + Keyword Args: + cookie (str): The cookie to filter + """ + if cookie.lower().startswith("max-age") or cookie.lower().startswith('expires'): + return False + return True + + +def expire_cookie(cookie): + """Add an expiration property to a specific cookie. + + Keyword Arguments: + cookie (str): The cookie string + """ + cookie_parts = cookie.split(';') + mapped = [c.strip() for c in cookie_parts] + filtered = filter(expire_cookie_filter, mapped) + listed = list(filtered) + _, settings = listed[0], listed[1:] + settings.insert(0, '') + settings.append("Expires=%s" % datetime.utcnow()) + return "; ".join(settings) + + +def http_post_with_retry(url, data, headers): + """Given a URL/Data/Headers make a POST request with exponential backoff. + + Used for retrieving token information from Cognito + + Keyword Args: + url (str): The URL to make the POST request to + data (Dict[str, str]): The dictionary of data elements to + send with the request (urlencoded internally) + headers (List[Dict[str, str]]): Any headers to send with + the POST request + """ + attempts = 1 + while attempts: + try: + encoded_data = urlencode(data).encode() + req = request.Request(url, encoded_data, headers) + res = request.urlopen(req).read() + read = res.decode('utf-8') + json_data = json.loads(read) + return json_data + # pylint: disable=broad-except + except Exception as err: + LOGGER.error('HTTP POST to %s failed (attempt %s)', url, attempts) + LOGGER.error(err) + LOGGER.error(traceback.print_exc()) + if attempts >= 5: + break + if attempts >= 2: + # After attempting twice do some exponential backoff with jitter + time.sleep( + (25 * (pow(2, attempts) + random() * attempts)) / 1000 + ) + finally: + attempts += 1 + + +def create_error_html(title, message, try_again_href): + """Create a basic error html page for exception returns. + + Keyword Args: + title (str): The title of the page + message (str): Any exception message + try_again_href (str): URL href to try the request again + """ + return "" + \ + "" + \ + " " + \ + " " + \ + " %s" % title + \ + " " + \ + " " + \ + "

%s

" % title + \ + "

ERROR: %s

" % message + \ + " Try again" % try_again_href + \ + " " + \ + "" diff --git a/runway/hooks/staticsite/auth_at_edge/templates/sign_out/__init__.py b/runway/hooks/staticsite/auth_at_edge/templates/sign_out/__init__.py new file mode 100644 index 000000000..499de6abd --- /dev/null +++ b/runway/hooks/staticsite/auth_at_edge/templates/sign_out/__init__.py @@ -0,0 +1,66 @@ +"""Sign the user out of Cognito and remove all Cookie Headers.""" + +import logging +from urllib.parse import urlencode # pylint: disable=no-name-in-module,import-error + +from shared import extract_and_parse_cookies, get_config, get_cookie_headers # noqa pylint: disable=import-error + +LOGGER = logging.getLogger(__name__) +CONFIG = get_config() + + +def handler(event, _context): + """Handle the signout event.""" + request = event['Records'][0]['cf']['request'] + domain_name = request['headers']['host'][0]['value'] + extracted = extract_and_parse_cookies( + request['headers'], + CONFIG.get('client_id') + ) + + if not extracted.get('idToken'): + return { + 'body': "Bad Request", + 'status': "400", + 'statusDescription': "Bad Request", + 'headers': CONFIG.get('cloud_front_headers') + } + + tokens = { + 'id_token': extracted.get('idToken'), + 'access_token': extracted.get('accessToken'), + 'refresh_token': extracted.get('refreshToken'), + } + query_string = { + 'logout_uri': 'https://%s%s' % (domain_name, CONFIG.get('redirect_path_sign_out')), + 'client_id': CONFIG.get('client_id') + } + + headers = { + # Redirect the user to logout + 'location': [ + { + 'key': 'location', + 'value': 'https://%s/logout?%s' % ( + CONFIG.get('cognito_auth_domain'), + urlencode(query_string) + ) + } + ], + 'set-cookie': get_cookie_headers( + CONFIG.get("client_id"), + CONFIG.get("oauth_scopes"), + tokens, + domain_name, + CONFIG.get('cookie_settings'), + # Make sure we expire all the tokens during retrieval + expire_all_tokens=True + ) + } + headers.update(CONFIG.get('cloud_front_headers')) + + return { + 'status': '307', + 'statusDescription': 'Temporary Redirect', + 'headers': headers + } diff --git a/runway/hooks/staticsite/build_staticsite.py b/runway/hooks/staticsite/build_staticsite.py index c4749f497..a7a6afd7a 100644 --- a/runway/hooks/staticsite/build_staticsite.py +++ b/runway/hooks/staticsite/build_staticsite.py @@ -5,15 +5,14 @@ import tempfile import zipfile -from boto3.s3.transfer import S3Transfer import boto3 +from boto3.s3.transfer import S3Transfer from ...cfngin.lookups.handlers.rxref import RxrefLookup from ...cfngin.session_cache import get_session - -from .util import get_hash_of_files +from ...s3_util import does_s3_object_exist, download_and_extract_to_mkdtemp from ...util import change_dir, run_commands -from ...s3_util import download_and_extract_to_mkdtemp, does_s3_object_exist +from .util import get_hash_of_files LOGGER = logging.getLogger(__name__) diff --git a/runway/hooks/staticsite/upload_staticsite.py b/runway/hooks/staticsite/upload_staticsite.py index 4b8591f14..ff573a94b 100644 --- a/runway/hooks/staticsite/upload_staticsite.py +++ b/runway/hooks/staticsite/upload_staticsite.py @@ -2,7 +2,6 @@ # TODO move to runway.cfngin.hooks on next major release import logging import time - from operator import itemgetter from ...cfngin.lookups.handlers.output import OutputLookup @@ -63,7 +62,7 @@ def sync(context, provider, **kwargs): "s3://%s/" % bucket_name, '--delete']) - if kwargs.get('cf_disabled', '') == 'true': + if kwargs.get('cf_disabled', False): display_static_website_url(kwargs.get('website_url'), provider, context) else: distribution = get_distribution_data(context, provider, **kwargs) diff --git a/runway/module/staticsite.py b/runway/module/staticsite.py index 78d973919..a72b0e74d 100644 --- a/runway/module/staticsite.py +++ b/runway/module/staticsite.py @@ -14,185 +14,386 @@ LOGGER = logging.getLogger('runway') -def ensure_valid_environment_config(module_name, config): - """Exit if config is invalid.""" - if not config.get('namespace'): - LOGGER.fatal("staticsite: module %s's environment configuration is " - "missing a namespace definition!", - module_name) - sys.exit(1) +def add_url_scheme(url): + """Add the scheme to an existing url. + + Args: + url (str): The current url + """ + if url.startswith('https://') or url.startswith('http://'): + return url + newurl = 'https://%s' % url + return newurl class StaticSite(RunwayModule): """Static website Runway Module.""" - def setup_website_module(self, command): + def __init__(self, context, path, options=None): + """Initialize.""" + super(StaticSite, self).__init__(context, path, options) + LOGGER.info(self.context.env_region) + self.name = self.options.get('name', self.options.get('path')) + self.user_options = self.options.get('options', {}) + self.parameters = self.options.get('parameters') + # Memoize + self.user_pool_id = '' + self.region = self.context.env_region + self._ensure_valid_environment_config() + self._ensure_cloudfront_with_auth_at_edge() + self._ensure_correct_region_with_auth_at_edge() + + def plan(self): + """Create website CFN module and run stacker diff.""" + if self.parameters: + self._setup_website_module(command='plan') + else: + LOGGER.info("Skipping staticsite plan of %s; no environment " + "config found for this environment/region", + self.options['path']) + + def deploy(self): + """Create website CFN module and run stacker build.""" + if self.parameters: + if self.parameters.get('staticsite_cf_disable', False) is False: + msg = ("Please Note: Initial creation or updates to distribution settings " + "(e.g. url rewrites) will take quite a while (up to an hour). " + "Unless you receive an error your deployment is still running.") + LOGGER.info(msg.upper()) + self._setup_website_module(command='deploy') + else: + LOGGER.info("Skipping staticsite deploy of %s; no environment " + "config found for this environment/region", + self.options['path']) + + def destroy(self): + """Create website CFN module and run stacker destroy.""" + if self.parameters: + self._setup_website_module(command='destroy') + else: + LOGGER.info("Skipping staticsite destroy of %s; no environment " + "config found for this environment/region", + self.options['path']) + + def _setup_website_module(self, command): """Create stacker configuration for website module.""" - name = self.options.get('name', self.options.get('path')) - # Make sure we remove any `.web` extension - directory_suffix = '.web' - if name.endswith(directory_suffix): - name = name[:-len(directory_suffix)] - - ensure_valid_environment_config( - name, - self.options['parameters'] + module_dir = self._create_module_directory() + self._create_dependencies_yaml(module_dir) + self._create_staticsite_yaml(module_dir) + + cfn = CloudFormation( + self.context, + module_dir, + {i: self.options[i] for i in self.options if i != 'class_path'} ) + getattr(cfn, command)() + def _create_module_directory(self): module_dir = tempfile.mkdtemp() LOGGER.info("staticsite: Generating CloudFormation configuration for " "module %s in %s", - name, + self.name, module_dir) + return module_dir - # Default parameter name matches build_staticsite hook - hash_param = "${namespace}-%s-hash" % name - if self.options.get('options', {}).get('source_hashing', {}).get('parameter'): - hash_param = self.options.get('options', {}).get('source_hashing', {}).get( - 'parameter' - ) - build_staticsite_args = self.options.copy() - - if not build_staticsite_args.get('options'): - build_staticsite_args['options'] = {} - build_staticsite_args['artifact_bucket_rxref_lookup'] = "%s-dependencies::ArtifactsBucketName" % name # noqa pylint: disable=line-too-long - build_staticsite_args['options']['namespace'] = '${namespace}' - build_staticsite_args['options']['name'] = name - build_staticsite_args['options']['path'] = os.path.join( - os.path.realpath(self.context.env_root), - self.path - ) - + def _create_dependencies_yaml(self, module_dir): with open(os.path.join(module_dir, '01-dependencies.yaml'), 'w') as output_stream: # noqa yaml.dump( {'namespace': '${namespace}', - 'stacker_bucket': '', + 'cfngin_bucket': '', 'stacks': { - "%s-dependencies" % name: { - 'class_path': 'runway.blueprints.staticsite.dependencies.Dependencies'}}, # noqa pylint: disable=line-too-long + "%s-dependencies" % self.name: { + 'class_path': 'runway.blueprints.staticsite.dependencies.Dependencies', + 'variables': self._get_dependencies_variables()}}, 'pre_destroy': [ {'path': 'runway.hooks.cleanup_s3.purge_bucket', 'required': True, 'args': { - 'bucket_rxref_lookup': "%s-dependencies::%s" % (name, i) # noqa + 'bucket_rxref_lookup': "%s-dependencies::%s" % (self.name, i) # noqa }} for i in ['AWSLogBucketName', 'ArtifactsBucketName'] ]}, output_stream, default_flow_style=False ) - site_stack_variables = { - 'AcmCertificateArn': '${default staticsite_acmcert_arn::undefined}', - 'Aliases': '${default staticsite_aliases::undefined}', - 'DisableCloudFront': '${default staticsite_cf_disable::undefined}', - 'RewriteDirectoryIndex': '${default staticsite_rewrite_directory_index::undefined}', # noqa pylint: disable=line-too-long - 'WAFWebACL': '${default staticsite_web_acl::undefined}' - } - parameters = self.options['parameters'] + def _create_staticsite_yaml(self, module_dir): + # Default parameter name matches build_staticsite hook + hash_param = self.user_options.get('source_hashing', {}).get( + 'parameter', + "${namespace}-%s-hash" % self.name + ) - if parameters.get('staticsite_acmcert_ssm_param'): - dep_msg = ('Use of the "staticsite_acmcert_ssm_param" option has ' - 'been deprecated. The "staticsite_acmcert_arn" option ' - 'with an "ssm" lookup sould be used instead.') - warnings.warn(dep_msg, DeprecationWarning) - LOGGER.warning(dep_msg) - site_stack_variables['AcmCertificateArn'] = '${ssmstore ${staticsite_acmcert_ssm_param}}' # noqa pylint: disable=line-too-long + build_staticsite_args = self.options.copy() or {} + build_staticsite_args['artifact_bucket_rxref_lookup'] = "%s-dependencies::ArtifactsBucketName" % self.name # noqa pylint: disable=line-too-long + build_staticsite_args['options']['namespace'] = '${namespace}' + build_staticsite_args['options']['name'] = self.name + build_staticsite_args['options']['path'] = os.path.join( + os.path.realpath(self.context.env_root), + self.path + ) - if parameters.get('staticsite_acmcert_arn') and \ - not parameters.get('staticsite_acmcert_ssm_param'): - site_stack_variables['AcmCertificateArn'] = \ - parameters['staticsite_acmcert_arn'] + site_stack_variables = self._get_site_stack_variables() + + class_path = 'staticsite.StaticSite' - if parameters.get('staticsite_enable_cf_logging', True): - site_stack_variables['LogBucketName'] = "${rxref %s-dependencies::AWSLogBucketName}" % name # noqa pylint: disable=line-too-long + pre_build = [{'path': 'runway.hooks.staticsite.build_staticsite.build', + 'required': True, + 'data_key': 'staticsite', + 'args': build_staticsite_args}] - if parameters.get('staticsite_cf_disable', False): - site_stack_variables['DisableCloudFront'] = "true" + post_build = [{'path': 'runway.hooks.staticsite.upload_staticsite.sync', + 'required': True, + 'args': { + 'bucket_output_lookup': '%s::BucketName' % self.name, + 'website_url': '%s::BucketWebsiteURL' % self.name, + 'cf_disabled': site_stack_variables['DisableCloudFront'], + 'distributionid_output_lookup': '%s::CFDistributionId' % (self.name), + 'distributiondomain_output_lookup': '%s::CFDistributionDomainName' % self.name}}] # noqa pylint: disable=line-too-long + + pre_destroy = [{'path': 'runway.hooks.cleanup_s3.purge_bucket', + 'required': True, + 'args': {'bucket_rxref_lookup': "%s::BucketName" % self.name}}] + + post_destroy = [{'path': 'runway.hooks.cleanup_ssm.delete_param', + 'args': {'parameter_name': hash_param}}] + + if self.parameters.get('staticsite_auth_at_edge', False): + class_path = 'auth_at_edge.AuthAtEdge' + domain_updater_variables = self._get_domain_updater_variables() + client_updater_variables = self._get_client_updater_variables( + self.name, + site_stack_variables + ) + lambda_config_variables = self._get_lambda_config_variables( + site_stack_variables + ) + + pre_build.append({ + 'path': 'runway.hooks.staticsite.auth_at_edge.domain_updater.update', + 'required': True, + 'data_key': 'aae_domain_updater', + 'args': domain_updater_variables + }) + pre_build.append({ + 'path': 'runway.hooks.staticsite.auth_at_edge.lambda_config.write', + 'required': True, + 'data_key': 'aae_lambda_config', + 'args': lambda_config_variables + }) + post_build.insert(0, { + 'path': 'runway.hooks.staticsite.auth_at_edge.client_updater.update', # noqa + 'required': True, + 'data_key': 'client_updater', + 'args': client_updater_variables + }) # If lambda_function_associations or custom_error_responses defined, # add to stack config for i in ['custom_error_responses', 'lambda_function_associations']: - if parameters.get("staticsite_%s" % i): - site_stack_variables[i] = parameters.pop("staticsite_%s" % i) + if self.parameters.get("staticsite_%s" % i): + site_stack_variables[i] = self.parameters.pop("staticsite_%s" % i) with open(os.path.join(module_dir, '02-staticsite.yaml'), 'w') as output_stream: # noqa yaml.dump( {'namespace': '${namespace}', - 'stacker_bucket': '', - 'pre_build': [ - {'path': 'runway.hooks.staticsite.build_staticsite.build', - 'required': True, - 'data_key': 'staticsite', - 'args': build_staticsite_args} - ], + 'cfngin_bucket': '', + 'pre_build': pre_build, 'stacks': { - name: { - 'class_path': 'runway.blueprints.staticsite.staticsite.StaticSite', # noqa + self.name: { + 'class_path': 'runway.blueprints.staticsite.%s' % class_path, # noqa 'variables': site_stack_variables}}, - 'post_build': [ - {'path': 'runway.hooks.staticsite.upload_staticsite.sync', - 'required': True, - 'args': { - 'bucket_output_lookup': '%s::BucketName' % name, - 'website_url': '%s::BucketWebsiteURL' % name, - 'cf_disabled': '%s' % site_stack_variables['DisableCloudFront'], - 'distributionid_output_lookup': '%s::CFDistributionId' % name, # noqa - 'distributiondomain_output_lookup': '%s::CFDistributionDomainName' % name}} # noqa pylint: disable=line-too-long - ], - 'pre_destroy': [ - {'path': 'runway.hooks.cleanup_s3.purge_bucket', - 'required': True, - 'args': { - 'bucket_rxref_lookup': "%s::BucketName" % name - }} - ], - 'post_destroy': [ - {'path': 'runway.hooks.cleanup_ssm.delete_param', - 'args': { - 'parameter_name': hash_param - }} - ]}, + 'post_build': post_build, + 'pre_destroy': pre_destroy, + 'post_destroy': post_destroy}, output_stream, default_flow_style=False ) - cfn = CloudFormation( - self.context, - module_dir, - {i: self.options[i] for i in self.options if i != 'class_path'} - ) - getattr(cfn, command)() + def _get_site_stack_variables(self): + site_stack_variables = { + 'Aliases': self.parameters.get('staticsite_aliases', '').split(','), + 'DisableCloudFront': self.parameters.get('staticsite_cf_disable', False), + 'RewriteDirectoryIndex': self.parameters.get( + 'staticsite_rewrite_directory_index', + '' + ), + 'RedirectPathSignIn': '${default staticsite_redirect_path_sign_in::/parseauth}', + 'RedirectPathSignOut': '${default staticsite_redirect_path_sign_out::/}', + 'RedirectPathAuthRefresh': + '${default staticsite_redirect_path_auth_refresh::/refreshauth}', + 'SignOutUrl': '${default staticsite_sign_out_url::/signout}', + 'WAFWebACL': self.parameters.get('staticsite_web_acl', '') + } - def plan(self): - """Create website CFN module and run stacker diff.""" - if self.options['parameters']: - self.setup_website_module(command='plan') - else: - LOGGER.info("Skipping staticsite plan of %s; no environment " - "config found for this environment/region", - self.options['path']) + if self.parameters.get('staticsite_acmcert_arn'): + site_stack_variables['AcmCertificateArn'] = \ + self.parameters['staticsite_acmcert_arn'] - def deploy(self): - """Create website CFN module and run stacker build.""" - parameters = self.options['parameters'] - if parameters: - if parameters.get('staticsite_cf_disable', False) is False: - msg = ("Please Note: Initial creation or updates to distribution settings " - "(e.g. url rewrites) will take quite a while (up to an hour). " - "Unless you receive an error your deployment is still running.") - LOGGER.info(msg.upper()) + if self.parameters.get('staticsite_acmcert_ssm_param'): + dep_msg = ('Use of the "staticsite_acmcert_ssm_param" option has ' + 'been deprecated. The "staticsite_acmcert_arn" option ' + 'with an "ssm" lookup should be used instead.') + warnings.warn(dep_msg, DeprecationWarning) + LOGGER.warning(dep_msg) + site_stack_variables['AcmCertificateArn'] = '${ssmstore ${staticsite_acmcert_ssm_param}}' # noqa pylint: disable=line-too-long - self.setup_website_module(command='deploy') - else: - LOGGER.info("Skipping staticsite deploy of %s; no environment " - "config found for this environment/region", - self.options['path']) + if self.parameters.get('staticsite_enable_cf_logging', True): + site_stack_variables['LogBucketName'] = "${rxref %s-dependencies::AWSLogBucketName}" % self.name # noqa pylint: disable=line-too-long - def destroy(self): - """Create website CFN module and run stacker destroy.""" - if self.options['parameters']: - self.setup_website_module(command='destroy') + if self.parameters.get('staticsite_auth_at_edge', False): + self._ensure_auth_at_edge_requirements() + site_stack_variables['UserPoolArn'] = self.parameters.get( + 'staticsite_user_pool_arn' + ) + site_stack_variables['NonSPAMode'] = self.parameters.get( + 'staticsite_non_spa', + False + ) + site_stack_variables['UserPoolId'] = self._extract_user_pool_id() + site_stack_variables['HttpHeaders'] = self._get_http_headers() + site_stack_variables['CookieSettings'] = self._get_cookie_settings() + site_stack_variables['OAuthScopes'] = self._get_oauth_scopes() + # pylint: disable=line-too-long + site_stack_variables['SupportedIdentityProviders'] = self._get_supported_identity_providers() # noqa else: - LOGGER.info("Skipping staticsite destroy of %s; no environment " - "config found for this environment/region", - self.options['path']) + # If lambda_function_associations or custom_error_responses defined, + # add to stack config. Only if not using Auth@Edge + for i in ['custom_error_responses', 'lambda_function_associations']: + if self.parameters.get("staticsite_%s" % i): + site_stack_variables[i] = self.parameters.get("staticsite_%s" % i) + self.parameters.pop("staticsite_%s" % i) + + return site_stack_variables + + def _extract_user_pool_id(self): + """Memoized extraction of the user pool id from the arn in Auth@Edge.""" + if self.user_pool_id: + return self.user_pool_id + + self.user_pool_id = self.parameters.get('staticsite_user_pool_arn').split('/')[-1:][0] + return self.user_pool_id + + def _get_cookie_settings(self): + """Retrieve the cookie settings from the variables or return the default.""" + if self.parameters.get('staticsite_cookie_settings'): + return self.parameters.get('staticsite_cookie_settings') + return { + "idToken": "Path=/; Secure; SameSite=Lax", + "accessToken": "Path=/; Secure; SameSite=Lax", + "refreshToken": "Path=/; Secure; SameSite=Lax", + "nonce": "Path=/; Secure; HttpOnly; Max-Age=1800; SameSite=Lax", + } + + def _get_http_headers(self): + """Retrieve the http headers from the variables or return the default.""" + if self.parameters.get('staticsite_http_headers'): + return self.parameters.get('staticsite_http_headers') + return { + "Content-Security-Policy": "default-src https: 'unsafe-eval' 'unsafe-inline'; " + # pylint: disable=line-too-long + "font-src 'self' 'unsafe-inline' 'unsafe-eval' data: https:; " # noqa + "object-src 'none'; " + # pylint: disable=line-too-long + "connect-src 'self' https://*.amazonaws.com https://*.amazoncognito.com", # noqa + "Strict-Transport-Security": "max-age=31536000; " + "includeSubdomains; " + "preload", + "Referrer-Policy": "same-origin", + "X-XSS-Protection": "1; mode=block", + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + } + + def _get_oauth_scopes(self): + """Retrieve the oauth scopes from the variables or return the default.""" + if self.parameters.get('staticsite_oauth_scopes'): + return self.parameters.get('staticsite_oauth_scopes') + return [ + 'phone', + 'email', + 'profile', + 'openid', + 'aws.cognito.signin.user.admin' + ] + + def _get_supported_identity_providers(self): + providers = self.parameters.get('staticsite_supported_identity_providers') + if providers: + return [provider.upper().strip() for provider in providers.split(',')] + return ['COGNITO'] + + def _get_dependencies_variables(self): + variables = {'OAuthScopes': self._get_oauth_scopes()} + if self.parameters.get('staticsite_auth_at_edge', False): + self._ensure_auth_at_edge_requirements() + + variables.update({ + 'AuthAtEdge': self.parameters.get( + 'staticsite_auth_at_edge', + False + ), + 'UserPoolId': self._extract_user_pool_id() + }) + return variables + + def _get_domain_updater_variables(self): + return { + 'client_id_output_lookup': "%s-dependencies::AuthAtEdgeClient" % self.name, # noqa pylint: disable=line-too-long + 'client_id': "${rxref %s-dependencies::AuthAtEdgeClient}" % self.name, + 'user_pool_id': self._extract_user_pool_id() + } + + def _get_lambda_config_variables(self, site_stack_variables): + return { + 'client_id': "${rxref %s-dependencies::AuthAtEdgeClient}" % self.name, # noqa pylint: disable=line-too-long + 'bucket': "${rxref %s-dependencies::ArtifactsBucketName}" % self.name, + 'cookie_settings': site_stack_variables['CookieSettings'], + 'http_headers': site_stack_variables['HttpHeaders'], + 'oauth_scopes': site_stack_variables['OAuthScopes'], + 'redirect_path_refresh': site_stack_variables['RedirectPathAuthRefresh'], + 'redirect_path_sign_in': site_stack_variables['RedirectPathSignIn'], + 'redirect_path_sign_out': site_stack_variables['RedirectPathSignOut'], + 'user_pool_id': self._extract_user_pool_id(), + } + + def _get_client_updater_variables(self, name, site_stack_variables): + aliases = list(map(add_url_scheme, site_stack_variables['Aliases'])) + return { + 'alternate_domains': [] if aliases[0] == '' else aliases, + 'client_id': "${rxref %s-dependencies::AuthAtEdgeClient}" % self.name, + 'distribution_domain': '${rxref %s::CFDistributionDomainName}' % name, + 'oauth_scopes': site_stack_variables['OAuthScopes'], + 'redirect_path_sign_in': site_stack_variables['RedirectPathSignIn'], + 'redirect_path_sign_out': site_stack_variables['RedirectPathSignOut'], + 'supported_identity_providers': site_stack_variables['SupportedIdentityProviders'], + 'user_pool_id': self._extract_user_pool_id(), + } + + def _ensure_auth_at_edge_requirements(self): + if not self.parameters.get('staticsite_user_pool_arn'): + LOGGER.fatal("A Cognito UserPool ARN is required with Auth@Edge") + sys.exit(1) + + def _ensure_correct_region_with_auth_at_edge(self): + """Exit if not in the us-east-1 region and deploying to Auth@Edge. + + Lambda@Edge is only available within the us-east-1 region. + """ + if self.parameters.get('staticsite_auth_at_edge', False) and self.region != 'us-east-1': + LOGGER.fatal("Auth@Edge must be deployed in us-east-1.") + sys.exit(1) + + def _ensure_cloudfront_with_auth_at_edge(self): + """Exit if both the Auth@Edge and CloudFront disablement are true.""" + if self.parameters.get('staticsite_cf_disable', False) and \ + self.parameters.get('staticsite_auth_at_edge', False): + LOGGER.fatal("CloudFront cannot be disabled when using Auth@Edge") + sys.exit(1) + + def _ensure_valid_environment_config(self): + """Exit if config is invalid.""" + if not self.parameters.get('namespace'): + LOGGER.fatal("staticsite: module %s's environment configuration is " + "missing a namespace definition!", + self.name) + sys.exit(1)