pyramid_cloudflare_access 1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ =======
2
+ Changes
3
+ =======
4
+
5
+ 1.1
6
+ ---
7
+
8
+ * Support for Heroku Review Apps.
9
+ [zupo]
10
+
11
+
12
+ 1.0
13
+ ---
14
+
15
+ * Initial release.
16
+ [dz0ny, zupo]
17
+
@@ -0,0 +1,26 @@
1
+ Copyright (c) 2022 Niteo GmbH
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are
5
+ permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this list of
8
+ conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list
11
+ of conditions and the following disclaimer in the documentation and/or other materials
12
+ provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE ABOVE COPYRIGHT HOLDERS ''AS IS'' AND ANY EXPRESS OR IMPLIED
15
+ WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
16
+ FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE ABOVE COPYRIGHT HOLDERS OR
17
+ CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
18
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
19
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
20
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
21
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
22
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ The views and conclusions contained in the software and documentation are those of the
25
+ authors and should not be interpreted as representing official policies, either expressed
26
+ or implied, of their employers.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.1
2
+ Name: pyramid_cloudflare_access
3
+ Version: 1.1
4
+ Summary: A bunch of helpers for successfully running Pyramid on Heroku.
5
+ Home-page: https://github.com/teamniteo/pyramid_cloudflare_access
6
+ License: BSD-3-Clause
7
+ Keywords: pyramid,cloudflare,pylons,web
8
+ Author: Niteo
9
+ Author-email: info@niteo.co
10
+ Requires-Python: >=3.9
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Pylons
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.7
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Dist: cryptography
27
+ Requires-Dist: pyjwt
28
+ Requires-Dist: pyramid (>=1.7,<2.0)
29
+ Description-Content-Type: text/x-rst
30
+
31
+ pyramid_cloudflare_access
32
+ =========================
33
+
34
+ Introduction
35
+ ------------
36
+
37
+
38
+ Installation
39
+ ------------
40
+
41
+ Just do
42
+
43
+ ``pip install pyramid_cloudflare_access``
44
+
45
+ or
46
+
47
+ ``easy_install pyramid_cloudflare_access``
48
+
49
+
50
+ Compatibility
51
+ -------------
52
+
53
+ pyramid_cloudflare_access runs with pyramid>=1.7 and python>=3.6.
54
+ Other versions might also work.
55
+
56
+ Heroku Review Apps deployed on `*.herokuapp.com` subdomains automatically skip the cloudflare access check as their domains are dynamic and as such can't be configured in Cloudflare dashboard in advance.
57
+
58
+ Usage
59
+ -----
60
+
61
+ Add Cloudfalre config to a production.ini::
62
+
63
+ pyramid_cloudflare_access.policy_audience = "my_audience"
64
+ pyramid_cloudflare_access.team = "https://team.cloudfare-access.com"
65
+
66
+
67
+ More information can be found at https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json#python-example
68
+
69
+ Usage example for the tween::
70
+
71
+ def main(global_config, **settings):
72
+ config = Configurator(settings=settings)
73
+ config.include('pyramid_cloudflare_access')
74
+ return config.make_wsgi_app()
75
+
76
+
77
+ Releasing
78
+ ---------
79
+
80
+ #. Update CHANGES.rst.
81
+ #. Update pyproject.toml version.
82
+ #. Run ``poetry check``.
83
+ #. Run ``poetry publish --build``.
84
+
85
+
86
+ We're hiring!
87
+ -------------
88
+
89
+ At Niteo we regularly contribute back to the Open Source community. If you do too, we'd like to invite you to `join our team
90
+ <https://niteo.co/careers/>`_!
91
+
@@ -0,0 +1,60 @@
1
+ pyramid_cloudflare_access
2
+ =========================
3
+
4
+ Introduction
5
+ ------------
6
+
7
+
8
+ Installation
9
+ ------------
10
+
11
+ Just do
12
+
13
+ ``pip install pyramid_cloudflare_access``
14
+
15
+ or
16
+
17
+ ``easy_install pyramid_cloudflare_access``
18
+
19
+
20
+ Compatibility
21
+ -------------
22
+
23
+ pyramid_cloudflare_access runs with pyramid>=1.7 and python>=3.6.
24
+ Other versions might also work.
25
+
26
+ Heroku Review Apps deployed on `*.herokuapp.com` subdomains automatically skip the cloudflare access check as their domains are dynamic and as such can't be configured in Cloudflare dashboard in advance.
27
+
28
+ Usage
29
+ -----
30
+
31
+ Add Cloudfalre config to a production.ini::
32
+
33
+ pyramid_cloudflare_access.policy_audience = "my_audience"
34
+ pyramid_cloudflare_access.team = "https://team.cloudfare-access.com"
35
+
36
+
37
+ More information can be found at https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json#python-example
38
+
39
+ Usage example for the tween::
40
+
41
+ def main(global_config, **settings):
42
+ config = Configurator(settings=settings)
43
+ config.include('pyramid_cloudflare_access')
44
+ return config.make_wsgi_app()
45
+
46
+
47
+ Releasing
48
+ ---------
49
+
50
+ #. Update CHANGES.rst.
51
+ #. Update pyproject.toml version.
52
+ #. Run ``poetry check``.
53
+ #. Run ``poetry publish --build``.
54
+
55
+
56
+ We're hiring!
57
+ -------------
58
+
59
+ At Niteo we regularly contribute back to the Open Source community. If you do too, we'd like to invite you to `join our team
60
+ <https://niteo.co/careers/>`_!
@@ -0,0 +1,45 @@
1
+ [tool.poetry]
2
+ name = "pyramid_cloudflare_access"
3
+ version = "1.1"
4
+ description = "A bunch of helpers for successfully running Pyramid on Heroku."
5
+ readme = "README.rst"
6
+ include = [ "CHANGES.rst", "COPYING.txt" ]
7
+ classifiers = [
8
+ "Operating System :: OS Independent",
9
+ "License :: OSI Approved :: BSD License",
10
+ "Programming Language :: Python",
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Framework :: Pylons",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.7",
16
+ "Topic :: Internet :: WWW/HTTP",
17
+ "Topic :: Internet :: WWW/HTTP :: WSGI",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+ packages = [
21
+ { include = "pyramid_cloudflare_access" },
22
+ ]
23
+ authors = [ "Niteo <info@niteo.co>" ]
24
+ license = "BSD-3-Clause"
25
+ homepage = "https://github.com/teamniteo/pyramid_cloudflare_access"
26
+ keywords = [ "pyramid", "cloudflare", "pylons", "web" ]
27
+
28
+ [tool.poetry.dependencies]
29
+ python = ">=3.9"
30
+ pyramid = ">=1.7, <2.0"
31
+ pyjwt = "*"
32
+ cryptography = "*"
33
+
34
+ [tool.poetry.dev-dependencies]
35
+ typecov = "*"
36
+ pytest-mock = "*"
37
+ pytest-cov = "*"
38
+ pytest = "*"
39
+ mypy = "*"
40
+ mock = "*"
41
+ isort = "*"
42
+ black = "*"
43
+ autoflake = "*"
44
+ "zope.testing" = "*"
45
+ pytest-freezegun = "*"
@@ -0,0 +1,71 @@
1
+ """Verify Cloudflare Access requests."""
2
+
3
+ from jwt import decode as jwt_decode
4
+ from jwt import PyJWKClient
5
+ from jwt.api_jwk import PyJWK
6
+ from pyramid.config import Configurator
7
+ from pyramid.request import Request
8
+ from pyramid.tweens import INGRESS
9
+
10
+ import pyramid.httpexceptions as exc
11
+ import typing as t
12
+
13
+
14
+ def includeme(config: Configurator) -> None: # pragma: no cover
15
+ # as possibly first tween in chain, order does not matter
16
+ config.add_tween("pyramid_cloudflare_access.CloudflareAccess", under=INGRESS)
17
+
18
+
19
+ def _get_public_keys(team: str) -> t.List[PyJWK]:
20
+ """Get JWK public keys for a team."""
21
+
22
+ url = f"{team}/cdn-cgi/access/certs"
23
+ client = PyJWKClient(url, cache_keys=False)
24
+ return client.get_signing_keys()
25
+
26
+
27
+ class CloudflareAccess:
28
+ """Deny access to Pyramid app when CF Access token is missing or invalid.
29
+
30
+ ref: https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json#python-example
31
+ """
32
+
33
+ def __init__(self, handler, registry):
34
+ settings = getattr(registry, "settings", {})
35
+ self.handler = handler
36
+ self.policy_audience = settings["pyramid_cloudflare_access.policy_audience"]
37
+ self.public_keys = _get_public_keys(settings["pyramid_cloudflare_access.team"])
38
+
39
+ def authenticated_request(self, request: Request) -> bool:
40
+ token = request.cookies.get("CF_Authorization")
41
+
42
+ if not token:
43
+ raise exc.HTTPBadRequest()
44
+
45
+ # Loop through the keys since we can't pass the key set to the decoder
46
+ for signing_key in self.public_keys:
47
+ try:
48
+ # decode returns the claims and verifies token against public keys
49
+ claims = jwt_decode(
50
+ token,
51
+ key=signing_key.key,
52
+ audience=self.policy_audience,
53
+ algorithms=["RS256"],
54
+ )
55
+ return claims is not None
56
+ except:
57
+ pass
58
+
59
+ return False
60
+
61
+ def __call__(self, request: Request):
62
+
63
+ # Support for Heroku Review apps
64
+ if "herokuapp.com" in request.headers.get("Host", ""):
65
+ return self.handler(request)
66
+
67
+ if not self.authenticated_request(request):
68
+ raise exc.HTTPForbidden()
69
+
70
+ # Continue with processing requests
71
+ return self.handler(request)
@@ -0,0 +1,3 @@
1
+ """
2
+ Pytest fixtures
3
+ """
@@ -0,0 +1,115 @@
1
+ """Tests for HerokuappAccess tween."""
2
+
3
+ from pyramid import testing
4
+ from pyramid.httpexceptions import HTTPBadRequest
5
+ from pyramid.httpexceptions import HTTPForbidden
6
+ from pyramid_cloudflare_access import CloudflareAccess
7
+
8
+ import pytest
9
+
10
+ # Taken from example at https://pyjwt.readthedocs.io/en/latest/usage.html#retrieve-rsa-signing-keys-from-a-jwks-endpoint
11
+ sample_jwk = {
12
+ "keys": [
13
+ {
14
+ "alg": "RS256",
15
+ "kty": "RSA",
16
+ "use": "sig",
17
+ "n": "0wtlJRY9-ru61LmOgieeI7_rD1oIna9QpBMAOWw8wTuoIhFQFwcIi7MFB7IEfelCPj08vkfLsuFtR8cG07EE4uvJ78bAqRjMsCvprWp4e2p7hqPnWcpRpDEyHjzirEJle1LPpjLLVaSWgkbrVaOD0lkWkP1T1TkrOset_Obh8BwtO-Ww-UfrEwxTyz1646AGkbT2nL8PX0trXrmira8GnrCkFUgTUS61GoTdb9bCJ19PLX9Gnxw7J0BtR0GubopXq8KlI0ThVql6ZtVGN2dvmrCPAVAZleM5TVB61m0VSXvGWaF6_GeOhbFoyWcyUmFvzWhBm8Q38vWgsSI7oHTkEw",
18
+ "e": "AQAB",
19
+ "kid": "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw",
20
+ "x5t": "NEE1QURBOTM4MzI5RkFDNTYxOTU1MDg2ODgwQ0UzMTk1QjYyRkRFQw",
21
+ "x5c": [
22
+ "MIIDBzCCAe+gAwIBAgIJNtD9Ozi6j2jJMA0GCSqGSIb3DQEBCwUAMCExHzAdBgNVBAMTFmRldi04N2V2eDlydS5hdXRoMC5jb20wHhcNMTkwNjIwMTU0NDU4WhcNMzMwMjI2MTU0NDU4WjAhMR8wHQYDVQQDExZkZXYtODdldng5cnUuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0wtlJRY9+ru61LmOgieeI7/rD1oIna9QpBMAOWw8wTuoIhFQFwcIi7MFB7IEfelCPj08vkfLsuFtR8cG07EE4uvJ78bAqRjMsCvprWp4e2p7hqPnWcpRpDEyHjzirEJle1LPpjLLVaSWgkbrVaOD0lkWkP1T1TkrOset/Obh8BwtO+Ww+UfrEwxTyz1646AGkbT2nL8PX0trXrmira8GnrCkFUgTUS61GoTdb9bCJ19PLX9Gnxw7J0BtR0GubopXq8KlI0ThVql6ZtVGN2dvmrCPAVAZleM5TVB61m0VSXvGWaF6/GeOhbFoyWcyUmFvzWhBm8Q38vWgsSI7oHTkEwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQlGXpmYaXFB7Q3eG69Uhjd4cFp/jAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBAIzQOF/h4T5WWAdjhcIwdNS7hS2Deq+UxxkRv+uavj6O9mHLuRG1q5onvSFShjECXaYT6OGibn7Ufw/JSm3+86ZouMYjBEqGh4OvWRkwARy1YTWUVDGpT2HAwtIq3lfYvhe8P4VfZByp1N4lfn6X2NcJflG+Q+mfXNmRFyyft3Oq51PCZyyAkU7bTun9FmMOyBtmJvQjZ8RXgBLvu9nUcZB8yTVoeUEg4cLczQlli/OkiFXhWgrhVr8uF0/9klslMFXtm78iYSgR8/oC+k1pSNd1+ESSt7n6+JiAQ2Co+ZNKta7LTDGAjGjNDymyoCrZpeuYQwwnHYEHu/0khjAxhXo="
23
+ ],
24
+ },
25
+ {
26
+ "alg": "RS256",
27
+ "kty": "RSA",
28
+ "use": "sig",
29
+ "n": "qMDEywqsPbiQbnSPVoKOb1HrQ_2KxI4JDe-AK-kbpb2Q3QXFl6IM3pJCvfYOm-f3DuEtBpll_Rg28WWeXl8pXAhmHk3V2Ig57f81uzGXg5xFtZDrqAG0chgwCQPD15FG00xrLDTvSDkIEPJZq-Y4IlJ3NbzQ8gn_JiappjMc8FjqQMz_4uUF-iIPU_aUgbLLtN98moKeNLAVV2lV3H5kVhNP8Fqd6piiH-mdma_KdY--GahAFC7Lt72_QtxnxowalbdkdMDim7paTeqxoZUKKHJsRVPXbiGx1zB3cfgBH7meU8ILv7JX3odu0juy0y2gagSaMkEd9-mcTLr8Bg0-5Q",
30
+ "e": "AQAB",
31
+ "kid": "Dt0jkFkY7KkmYdDb2BaI1",
32
+ "x5t": "lhmczC7hbLpBZh6MBSygH1D9qeE",
33
+ "x5c": [
34
+ "MIIDBzCCAe+gAwIBAgIJHjJKyTNJE/wIMA0GCSqGSIb3DQEBCwUAMCExHzAdBgNVBAMTFmRldi04N2V2eDlydS5hdXRoMC5jb20wHhcNMjAwMzExMjA1OTM5WhcNMzMxMTE4MjA1OTM5WjAhMR8wHQYDVQQDExZkZXYtODdldng5cnUuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqMDEywqsPbiQbnSPVoKOb1HrQ/2KxI4JDe+AK+kbpb2Q3QXFl6IM3pJCvfYOm+f3DuEtBpll/Rg28WWeXl8pXAhmHk3V2Ig57f81uzGXg5xFtZDrqAG0chgwCQPD15FG00xrLDTvSDkIEPJZq+Y4IlJ3NbzQ8gn/JiappjMc8FjqQMz/4uUF+iIPU/aUgbLLtN98moKeNLAVV2lV3H5kVhNP8Fqd6piiH+mdma/KdY++GahAFC7Lt72/QtxnxowalbdkdMDim7paTeqxoZUKKHJsRVPXbiGx1zB3cfgBH7meU8ILv7JX3odu0juy0y2gagSaMkEd9+mcTLr8Bg0+5QIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQxVQqyrrAtXsBznZj2GN4nfJf+sTAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBAGsIl6yxIG8GOkHlfcSEa4//4WDxwfw8lg6zNPri6nhYtF1kbHTO5PqUbE+kasMnvqEV5Y0QXvyxwIjLLbbYiySK6aWp5XS2Wy5hYlMjXOimAw6mwbkNVhujRsPjTY3P+bv/9eiv2zO9yEfzfmfr6jhYcmnOdTFgAujsL4AyDpUh4/jKDtDNFl6lMdn8J7DcdRNZM/8OsAk6GgZYlzStfh4aI/uE3ekJ84XAxxdHUzwDUu5B8CetmHvfxQvV9MjmozLR8SbkTEhUv4//tr8SfGc8jS78E5w8NJN6DtYVcUNweHrAlvQXRevCmBRu3D9hKARjXfTBqilBBF9nNt93mYM="
35
+ ],
36
+ },
37
+ ]
38
+ }
39
+ sample_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"
40
+ sample_audience = "https://expenses-api"
41
+
42
+
43
+ @pytest.mark.freeze_time("2019-10-25 12:36:00")
44
+ def test_happy_path(mocker) -> None:
45
+ """Test that JWT token is parsed and authorized."""
46
+
47
+ mocker.patch(
48
+ "pyramid_cloudflare_access.PyJWKClient.fetch_data", return_value=sample_jwk
49
+ )
50
+ tween_handler = mocker.Mock()
51
+
52
+ request = testing.DummyRequest()
53
+ request.cookies = {"CF_Authorization": sample_token}
54
+ request.registry.settings = {
55
+ "pyramid_cloudflare_access.policy_audience": sample_audience,
56
+ "pyramid_cloudflare_access.team": "https://foo.cloudflareaccess.com",
57
+ }
58
+
59
+ CloudflareAccess(tween_handler, request.registry)(request)
60
+ tween_handler.assert_called_with(request)
61
+
62
+
63
+ def test_missing_cookie(mocker) -> None:
64
+ """Test that access is denied on wrong request."""
65
+
66
+ mocker.patch(
67
+ "pyramid_cloudflare_access.PyJWKClient.fetch_data", return_value=sample_jwk
68
+ )
69
+ tween_handler = mocker.Mock()
70
+
71
+ request = testing.DummyRequest()
72
+ request.registry.settings = {
73
+ "pyramid_cloudflare_access.policy_audience": sample_audience,
74
+ "pyramid_cloudflare_access.team": "https://foo.cloudflareaccess.com",
75
+ }
76
+ with pytest.raises(HTTPBadRequest):
77
+ CloudflareAccess(tween_handler, request.registry)(request)
78
+
79
+
80
+ def test_auth_failed(mocker) -> None:
81
+ """Test that access is denied on expired auth."""
82
+
83
+ mocker.patch(
84
+ "pyramid_cloudflare_access.PyJWKClient.fetch_data", return_value=sample_jwk
85
+ )
86
+ tween_handler = mocker.Mock()
87
+
88
+ request = testing.DummyRequest()
89
+ request.cookies = {"CF_Authorization": sample_token}
90
+ request.registry.settings = {
91
+ "pyramid_cloudflare_access.policy_audience": sample_audience,
92
+ "pyramid_cloudflare_access.team": "https://foo.cloudflareaccess.com",
93
+ }
94
+ with pytest.raises(HTTPForbidden):
95
+ CloudflareAccess(tween_handler, request.registry)(request)
96
+
97
+ def test_herokuapp(mocker) -> None:
98
+ """Test that Cloudflare Access is skipped for Heroku-hosted apps.
99
+
100
+ This is to support Review Apps that have dynamic *.herokuapp.com hostname,
101
+ that cannot be configured as a domain for Cloudflare Access application
102
+ in Cloudflare dashboard.
103
+ """
104
+ tween_handler = mocker.Mock()
105
+
106
+ request = testing.DummyRequest()
107
+ request.cookies = {}
108
+ request.registry.settings = {
109
+ "pyramid_cloudflare_access.policy_audience": sample_audience,
110
+ "pyramid_cloudflare_access.team": "https://foo.cloudflareaccess.com",
111
+ }
112
+ request.headers['Host'] = "foo.herokuapp.com"
113
+
114
+ CloudflareAccess(tween_handler, request.registry)(request)
115
+ tween_handler.assert_called_with(request)