From 73c8e2e4afee39d8f096b1a2654d2e05941882c3 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 11:44:11 -0600 Subject: [PATCH 01/11] fix(cloudrun): update dependencies to latest versions --- run/service-auth/requirements-test.txt | 2 +- run/service-auth/requirements.txt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/run/service-auth/requirements-test.txt b/run/service-auth/requirements-test.txt index 15d066af319..2c78728ca5d 100644 --- a/run/service-auth/requirements-test.txt +++ b/run/service-auth/requirements-test.txt @@ -1 +1 @@ -pytest==8.2.0 +pytest==8.3.5 diff --git a/run/service-auth/requirements.txt b/run/service-auth/requirements.txt index 505e197f8d2..f4029743b04 100644 --- a/run/service-auth/requirements.txt +++ b/run/service-auth/requirements.txt @@ -1,5 +1,5 @@ google-auth==2.38.0 -requests==2.31.0 -Flask==3.0.3 +requests==2.32.3 +Flask==3.1.0 gunicorn==23.0.0 -Werkzeug==3.0.3 +Werkzeug==3.1.3 From 82c31f98cf57bd3ef90dae9dcc8e49285388dc09 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 11:44:48 -0600 Subject: [PATCH 02/11] fix(cloudrun): delete noxfile_config.py as it was outdated for Python 3.8 and is not required anymore --- run/service-auth/noxfile_config.py | 36 ------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 run/service-auth/noxfile_config.py diff --git a/run/service-auth/noxfile_config.py b/run/service-auth/noxfile_config.py deleted file mode 100644 index 48bcf1c6b23..00000000000 --- a/run/service-auth/noxfile_config.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2022 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Default TEST_CONFIG_OVERRIDE for python repos. - -# You can copy this file into your directory, then it will be imported from -# the noxfile.py. - -# The source of truth: -# https://github.com/GoogleCloudPlatform/python-docs-samples/blob/main/noxfile_config.py - -TEST_CONFIG_OVERRIDE = { - # You can opt out from the test for specific Python versions. - # We only run the cloud run tests in py38 session. - "ignored_versions": ["2.7", "3.6", "3.7"], - # An envvar key for determining the project id to use. Change it - # to 'BUILD_SPECIFIC_GCLOUD_PROJECT' if you want to opt in using a - # build specific Cloud project. You can also use your own string - # to use your own Cloud project. - "gcloud_project_env": "GOOGLE_CLOUD_PROJECT", - # 'gcloud_project_env': 'BUILD_SPECIFIC_GCLOUD_PROJECT', - # A dictionary you want to inject into your test. Don't put any - # secrets here. These values will override predefined values. - "envs": {}, -} From 501b846df0b9467302f6de2b578c2da55a395f7d Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 11:50:15 -0600 Subject: [PATCH 03/11] fix(cloudrun): refactor sample and test to comply with current Style Guide. - Apply fixes for Style Guide - Add type hints - Rename variables to be consistent with their values - Add HTTP error codes constants to avoid managing 'magic numbers' - Rewrite comments for accuracy --- run/service-auth/app.py | 2 +- run/service-auth/receive.py | 32 +++++++++-------- run/service-auth/receive_test.py | 60 ++++++++++++++++++++++---------- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/run/service-auth/app.py b/run/service-auth/app.py index 40a96efe0b6..fb0cbeb6f25 100644 --- a/run/service-auth/app.py +++ b/run/service-auth/app.py @@ -22,7 +22,7 @@ @app.route("/") -def main(): +def main() -> str: """Example route for receiving authorized requests.""" try: return receive_authorized_get_request(request) diff --git a/run/service-auth/receive.py b/run/service-auth/receive.py index 7d334109844..0b8c161d2b0 100644 --- a/run/service-auth/receive.py +++ b/run/service-auth/receive.py @@ -12,39 +12,43 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -Demonstrates how to receive authenticated service-to-service requests, eg -for Cloud Run or Cloud Functions +"""Demonstrates how to receive authenticated service-to-service requests. + +For example for Cloud Run or Cloud Functions. """ # [START cloudrun_service_to_service_receive] +from flask import Request from google.auth.transport import requests from google.oauth2 import id_token -def receive_authorized_get_request(request): - """Parse the authorization header and decode the information - being sent by the Bearer token. +def receive_authorized_get_request(request: Request) -> str: + """Parse the authorization header + and decode the information being sent by the Bearer token. Args: - request: Flask request object + request: Flask request object. Returns: - The email from the request's Authorization header. + One of the following: + a) The email from the request's Authorization header. + b) A welcome message for anonymous users. + c) An error if the authentication method is not "Bearer". """ auth_header = request.headers.get("Authorization") if auth_header: - # split the auth type and value from the header. + # Split the auth type and value from the header. auth_type, creds = auth_header.split(" ", 1) if auth_type.lower() == "bearer": - claims = id_token.verify_token(creds, requests.Request()) - return f"Hello, {claims['email']}!\n" - + # Find more information about `verify_token()` here: + # https://google-auth.readthedocs.io/en/master/reference/google.oauth2.id_token.html#google.oauth2.id_token.verify_token + decoded_token = id_token.verify_token(creds, requests.Request()) + return f"Hello, {decoded_token['email']}!\n" else: return f"Unhandled header format ({auth_type}).\n" - return "Hello, anonymous user.\n" - + return "Hello, anonymous user.\n" # [END cloudrun_service_to_service_receive] diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index 2d20f59f370..9a25dbee8e8 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -25,15 +25,26 @@ from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry +PROJECT_ID = os.environ["GOOGLE_CLOUD_PROJECT"] + +HTTP_STATUS_OK = 200 +HTTP_STATUS_BAD_REQUEST = 400 +HTTP_STATUS_UNAUTHORIZED = 401 +HTTP_STATUS_FORBIDDEN = 403 +HTTP_STATUS_NOT_FOUND = 404 +HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 +HTTP_STATUS_BAD_GATEWAY = 502 +HTTP_STATUS_SERVICE_UNAVAILABLE = 503 +HTTP_STATUS_GATEWAY_TIMEOUT = 504 + @pytest.fixture() -def services(): - # Unique suffix to create distinct service names - suffix = uuid.uuid4().hex +def service() -> tuple[str, str]: + # Add a unique suffix to create distinct service names. + suffix = uuid.uuid4() service_name = f"receive-{suffix}" - project = os.environ["GOOGLE_CLOUD_PROJECT"] - # Deploy receive Cloud Run Service + # Deploy the Cloud Run Service. subprocess.run( [ "gcloud", @@ -41,17 +52,17 @@ def services(): "deploy", service_name, "--project", - project, + PROJECT_ID, "--source", ".", "--region=us-central1", "--allow-unauthenticated", "--quiet", ], + # Rise a CalledProcessError exception for a non-zero exit code. check=True, ) - # Get the URL for the service endpoint_url = ( subprocess.run( [ @@ -61,7 +72,7 @@ def services(): "describe", service_name, "--project", - project, + PROJECT_ID, "--region=us-central1", "--format=value(status.url)", ], @@ -84,6 +95,7 @@ def services(): yield endpoint_url, token + # Clean-up after running the test. subprocess.run( [ "gcloud", @@ -92,7 +104,7 @@ def services(): "delete", service_name, "--project", - project, + PROJECT_ID, "--async", "--region=us-central1", "--quiet", @@ -101,19 +113,28 @@ def services(): ) -def test_auth(services): - url = services[0] - token = services[1] +def test_authentication_on_cloud_run(service: tuple[str, str]) -> None: + endpoint_url = service[0] + token = service[1] - req = request.Request(url) + req = request.Request(endpoint_url) try: _ = request.urlopen(req) except error.HTTPError as e: - assert e.code == 403 + assert e.code == HTTP_STATUS_FORBIDDEN retry_strategy = Retry( total=3, - status_forcelist=[400, 401, 403, 404, 500, 502, 503, 504], + status_forcelist=[ + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_UNAUTHORIZED, + HTTP_STATUS_FORBIDDEN, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_BAD_GATEWAY, + HTTP_STATUS_SERVICE_UNAVAILABLE, + HTTP_STATUS_GATEWAY_TIMEOUT, + ], allowed_methods=["GET", "POST"], backoff_factor=3, ) @@ -122,8 +143,9 @@ def test_auth(services): client = requests.session() client.mount("https://", adapter) - response = client.get(url, headers={"Authorization": f"Bearer {token}"}) + response = client.get(endpoint_url, headers={"Authorization": f"Bearer {token}"}) + response_content = response.content.decode("UTF-8") - assert response.status_code == 200 - assert "Hello" in response.content.decode("UTF-8") - assert "anonymous" not in response.content.decode("UTF-8") + assert response.status_code == HTTP_STATUS_OK + assert "Hello" in response_content + assert "anonymous" not in response_content From b8d992309ea48f09a7861839754426858a291065 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 12:13:26 -0600 Subject: [PATCH 04/11] fix(cloudrun): migrate region tag step 1 - add region with prefix auth as this sample is not specific to Cloud Run --- run/service-auth/receive.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/run/service-auth/receive.py b/run/service-auth/receive.py index 0b8c161d2b0..273215ddfec 100644 --- a/run/service-auth/receive.py +++ b/run/service-auth/receive.py @@ -17,6 +17,7 @@ For example for Cloud Run or Cloud Functions. """ +# [START auth_service_to_service_receive] # [START cloudrun_service_to_service_receive] from flask import Request @@ -52,3 +53,4 @@ def receive_authorized_get_request(request: Request) -> str: return "Hello, anonymous user.\n" # [END cloudrun_service_to_service_receive] +# [END auth_service_to_service_receive] \ No newline at end of file From 6a52a71d4398c74c73e01eb5a651eb78c207f94c Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 12:15:02 -0600 Subject: [PATCH 05/11] fix(cloudrun): add comment suggested by gemini-code-assist --- run/service-auth/receive_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index 9a25dbee8e8..b74f296fd5c 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -40,9 +40,9 @@ @pytest.fixture() def service() -> tuple[str, str]: + """Deploys a Cloud Run service and returns its URL and a valid token.""" # Add a unique suffix to create distinct service names. - suffix = uuid.uuid4() - service_name = f"receive-{suffix}" + service_name = f"receive-{uuid.uuid4()}" # Deploy the Cloud Run Service. subprocess.run( From 1f20f5df2c593a708c1927ea2206b27d7a73668a Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 12:15:43 -0600 Subject: [PATCH 06/11] fix(cloudrun): add missing new line --- run/service-auth/receive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run/service-auth/receive.py b/run/service-auth/receive.py index 273215ddfec..e5343030c0b 100644 --- a/run/service-auth/receive.py +++ b/run/service-auth/receive.py @@ -53,4 +53,4 @@ def receive_authorized_get_request(request: Request) -> str: return "Hello, anonymous user.\n" # [END cloudrun_service_to_service_receive] -# [END auth_service_to_service_receive] \ No newline at end of file +# [END auth_service_to_service_receive] From f13a641a5996e670f5cc892db5df2c13b05795f2 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 16:36:55 -0600 Subject: [PATCH 07/11] fix(cloudrun): add test for anonymous request, and rename key function and region tag --- run/service-auth/app.py | 4 +-- run/service-auth/receive.py | 22 +++++++------ run/service-auth/receive_test.py | 54 +++++++++++++++++++++++++------- 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/run/service-auth/app.py b/run/service-auth/app.py index fb0cbeb6f25..6b425a1fde9 100644 --- a/run/service-auth/app.py +++ b/run/service-auth/app.py @@ -16,7 +16,7 @@ from flask import Flask, request -from receive import receive_authorized_get_request +from receive import receive_request_and_parse_auth_header app = Flask(__name__) @@ -25,7 +25,7 @@ def main() -> str: """Example route for receiving authorized requests.""" try: - return receive_authorized_get_request(request) + return receive_request_and_parse_auth_header(request) except Exception as e: return f"Error verifying ID token: {e}" diff --git a/run/service-auth/receive.py b/run/service-auth/receive.py index e5343030c0b..063b9d24548 100644 --- a/run/service-auth/receive.py +++ b/run/service-auth/receive.py @@ -17,17 +17,18 @@ For example for Cloud Run or Cloud Functions. """ -# [START auth_service_to_service_receive] +# [START auth_validate_and_decode_bearer_token_on_flask] # [START cloudrun_service_to_service_receive] from flask import Request +from google.auth.exceptions import GoogleAuthError from google.auth.transport import requests from google.oauth2 import id_token -def receive_authorized_get_request(request: Request) -> str: - """Parse the authorization header - and decode the information being sent by the Bearer token. +def receive_request_and_parse_auth_header(request: Request) -> str: + """Parse the authorization header, validate the Bearer token + and decode the token to get its information. Args: request: Flask request object. @@ -36,7 +37,7 @@ def receive_authorized_get_request(request: Request) -> str: One of the following: a) The email from the request's Authorization header. b) A welcome message for anonymous users. - c) An error if the authentication method is not "Bearer". + c) An error description. """ auth_header = request.headers.get("Authorization") if auth_header: @@ -44,13 +45,16 @@ def receive_authorized_get_request(request: Request) -> str: auth_type, creds = auth_header.split(" ", 1) if auth_type.lower() == "bearer": - # Find more information about `verify_token()` here: + # Find more information about `verify_token` function here: # https://google-auth.readthedocs.io/en/master/reference/google.oauth2.id_token.html#google.oauth2.id_token.verify_token - decoded_token = id_token.verify_token(creds, requests.Request()) - return f"Hello, {decoded_token['email']}!\n" + try: + decoded_token = id_token.verify_token(creds, requests.Request()) + return f"Hello, {decoded_token['email']}!\n" + except GoogleAuthError as e: + return f"Invalid token: {e}\n" else: return f"Unhandled header format ({auth_type}).\n" return "Hello, anonymous user.\n" # [END cloudrun_service_to_service_receive] -# [END auth_service_to_service_receive] +# [END auth_validate_and_decode_bearer_token_on_flask] diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index b74f296fd5c..227244a9212 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -37,8 +37,19 @@ HTTP_STATUS_SERVICE_UNAVAILABLE = 503 HTTP_STATUS_GATEWAY_TIMEOUT = 504 - -@pytest.fixture() +STATUS_FORCELIST = [ + HTTP_STATUS_BAD_REQUEST, + HTTP_STATUS_UNAUTHORIZED, + HTTP_STATUS_FORBIDDEN, + HTTP_STATUS_NOT_FOUND, + HTTP_STATUS_INTERNAL_SERVER_ERROR, + HTTP_STATUS_BAD_GATEWAY, + HTTP_STATUS_SERVICE_UNAVAILABLE, + HTTP_STATUS_GATEWAY_TIMEOUT, +], + + +@pytest.fixture(scope="module") def service() -> tuple[str, str]: """Deploys a Cloud Run service and returns its URL and a valid token.""" # Add a unique suffix to create distinct service names. @@ -125,16 +136,7 @@ def test_authentication_on_cloud_run(service: tuple[str, str]) -> None: retry_strategy = Retry( total=3, - status_forcelist=[ - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_UNAUTHORIZED, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_BAD_GATEWAY, - HTTP_STATUS_SERVICE_UNAVAILABLE, - HTTP_STATUS_GATEWAY_TIMEOUT, - ], + status_forcelist=STATUS_FORCELIST, allowed_methods=["GET", "POST"], backoff_factor=3, ) @@ -149,3 +151,31 @@ def test_authentication_on_cloud_run(service: tuple[str, str]) -> None: assert response.status_code == HTTP_STATUS_OK assert "Hello" in response_content assert "anonymous" not in response_content + + +def test_anonymous_request_on_cloud_run(service: tuple[str, str]) -> None: + endpoint_url = service[0] + + req = request.Request(endpoint_url) + try: + _ = request.urlopen(req) + except error.HTTPError as e: + assert e.code == HTTP_STATUS_FORBIDDEN + + retry_strategy = Retry( + total=3, + status_forcelist=STATUS_FORCELIST, + allowed_methods=["GET", "POST"], + backoff_factor=3, + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + + client = requests.session() + client.mount("https://", adapter) + + response = client.get(endpoint_url) + response_content = response.content.decode("UTF-8") + + assert response.status_code == HTTP_STATUS_OK + assert "Hello" in response_content + assert "anonymous" in response_content From 11e8b02dbe23c7bd55acc7702e6c3a0c794651b2 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 16:50:02 -0600 Subject: [PATCH 08/11] fix(cloudrun): add sample to detect an invalid token --- run/service-auth/receive_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index 227244a9212..f03391f865e 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -179,3 +179,31 @@ def test_anonymous_request_on_cloud_run(service: tuple[str, str]) -> None: assert response.status_code == HTTP_STATUS_OK assert "Hello" in response_content assert "anonymous" in response_content + + +def test_invalid_token(service: tuple[str, str]) -> None: + endpoint_url = service[0] + + req = request.Request(endpoint_url) + try: + _ = request.urlopen(req) + except error.HTTPError as e: + assert e.code == HTTP_STATUS_FORBIDDEN + + retry_strategy = Retry( + total=3, + status_forcelist=STATUS_FORCELIST, + allowed_methods=["GET", "POST"], + backoff_factor=3, + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + + client = requests.session() + client.mount("https://", adapter) + + # Sample token from https://jwt.io for John Doe. + token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + response = client.get(endpoint_url, headers={"Authorization": f"Bearer {token}"}) + response_content = response.content.decode("UTF-8") + + assert "Invalid token" in response_content \ No newline at end of file From 29603aa9e27730ef205f2d4bc4ba7fd1faf32200 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 7 Apr 2025 16:57:03 -0600 Subject: [PATCH 09/11] fix(cloudrun): add missing new line --- run/service-auth/receive_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index f03391f865e..341af04770a 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -206,4 +206,4 @@ def test_invalid_token(service: tuple[str, str]) -> None: response = client.get(endpoint_url, headers={"Authorization": f"Bearer {token}"}) response_content = response.content.decode("UTF-8") - assert "Invalid token" in response_content \ No newline at end of file + assert "Invalid token" in response_content From cfb0da3f6b1654c35e027fc1d1a53753ac6154f5 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Tue, 8 Apr 2025 11:15:58 -0600 Subject: [PATCH 10/11] fix(cloudrun): apply feedback from PR Review by glasnt PR Review https://github.com/GoogleCloudPlatform/python-docs-samples/pull/13292#pullrequestreview-2748503425 - Replace HTTP status codes with IntEnum from http.HTTPStatus - Replace hard coded token with a fixture for a fake token - Remove duplicated code for the client, and moving it to a test fixture Also - Add HTTP codes to the app.py `/` endpoint - Replace 'UTF-8' with 'utf-8' to follow the official documentation --- run/service-auth/app.py | 11 +- run/service-auth/data/privatekey.pem | 27 ++++ run/service-auth/receive_test.py | 195 ++++++++++++++------------- 3 files changed, 136 insertions(+), 97 deletions(-) create mode 100644 run/service-auth/data/privatekey.pem diff --git a/run/service-auth/app.py b/run/service-auth/app.py index 6b425a1fde9..a5ef0c046b5 100644 --- a/run/service-auth/app.py +++ b/run/service-auth/app.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from http import HTTPStatus import os from flask import Flask, request @@ -25,9 +26,15 @@ def main() -> str: """Example route for receiving authorized requests.""" try: - return receive_request_and_parse_auth_header(request) + response = receive_request_and_parse_auth_header(request) + + status = HTTPStatus.UNAUTHORIZED + if "Hello" in response: + status = HTTPStatus.OK + + return response, status except Exception as e: - return f"Error verifying ID token: {e}" + return f"Error verifying ID token: {e}", HTTPStatus.UNAUTHORIZED if __name__ == "__main__": diff --git a/run/service-auth/data/privatekey.pem b/run/service-auth/data/privatekey.pem new file mode 100644 index 00000000000..2a00cd0088f --- /dev/null +++ b/run/service-auth/data/privatekey.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj +7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/ +xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs +SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18 +pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk +SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk +nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq +HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y +nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9 +IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2 +YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU +Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ +vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP +B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl +aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2 +eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI +aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk +klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ +CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu +UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg +soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28 +bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH +504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL +YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx +BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg== +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index 341af04770a..05e3e6d47b6 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -15,45 +15,71 @@ # This test deploys a secure application running on Cloud Run # to test that the authentication sample works properly. +from http import HTTPStatus import os import subprocess +import time from urllib import error, request import uuid +from google.auth import crypt +from google.auth import jwt + import pytest + import requests from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry +from requests.sessions import Session PROJECT_ID = os.environ["GOOGLE_CLOUD_PROJECT"] -HTTP_STATUS_OK = 200 -HTTP_STATUS_BAD_REQUEST = 400 -HTTP_STATUS_UNAUTHORIZED = 401 -HTTP_STATUS_FORBIDDEN = 403 -HTTP_STATUS_NOT_FOUND = 404 -HTTP_STATUS_INTERNAL_SERVER_ERROR = 500 -HTTP_STATUS_BAD_GATEWAY = 502 -HTTP_STATUS_SERVICE_UNAVAILABLE = 503 -HTTP_STATUS_GATEWAY_TIMEOUT = 504 - STATUS_FORCELIST = [ - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_UNAUTHORIZED, - HTTP_STATUS_FORBIDDEN, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_INTERNAL_SERVER_ERROR, - HTTP_STATUS_BAD_GATEWAY, - HTTP_STATUS_SERVICE_UNAVAILABLE, - HTTP_STATUS_GATEWAY_TIMEOUT, + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + HTTPStatus.NOT_FOUND, + HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.BAD_GATEWAY, + HTTPStatus.SERVICE_UNAVAILABLE, + HTTPStatus.GATEWAY_TIMEOUT, ], +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") + +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + + +@pytest.fixture(scope="module") +def signer() -> crypt.RSASigner: + return crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") + + +@pytest.fixture +def fake_token(signer: crypt.RSASigner) -> str: + now = int(time.time()) + payload = { + "aud": "example.com", + "azp": "1234567890", + "email": "example@example.iam.gserviceaccount.com", + "email_verified": True, + "iat": now, + "exp": now + 3600, + "iss": "https://accounts.google.com", + "sub": "1234567890", + } + header = {"alg": "RS256", "kid": signer.key_id, "typ": "JWT"} + token_str = jwt.encode(signer, payload, header=header).decode("utf-8") + + return token_str + + @pytest.fixture(scope="module") -def service() -> tuple[str, str]: - """Deploys a Cloud Run service and returns its URL and a valid token.""" +def service_name() -> str: # Add a unique suffix to create distinct service names. - service_name = f"receive-{uuid.uuid4()}" + service_name_str = f"receive-{uuid.uuid4().hex}" # Deploy the Cloud Run Service. subprocess.run( @@ -61,7 +87,7 @@ def service() -> tuple[str, str]: "gcloud", "run", "deploy", - service_name, + service_name_str, "--project", PROJECT_ID, "--source", @@ -74,7 +100,29 @@ def service() -> tuple[str, str]: check=True, ) - endpoint_url = ( + yield service_name_str + + # Clean-up after running the test. + subprocess.run( + [ + "gcloud", + "run", + "services", + "delete", + service_name_str, + "--project", + PROJECT_ID, + "--async", + "--region=us-central1", + "--quiet", + ], + check=True, + ) + + +@pytest.fixture(scope="module") +def endpoint_url(service_name: str) -> str: + endpoint_url_str = ( subprocess.run( [ "gcloud", @@ -94,7 +142,12 @@ def service() -> tuple[str, str]: .decode() ) - token = ( + return endpoint_url_str + + +@pytest.fixture(scope="module") +def token() -> str: + token_str = ( subprocess.run( ["gcloud", "auth", "print-identity-token"], stdout=subprocess.PIPE, @@ -104,35 +157,16 @@ def service() -> tuple[str, str]: .decode() ) - yield endpoint_url, token - - # Clean-up after running the test. - subprocess.run( - [ - "gcloud", - "run", - "services", - "delete", - service_name, - "--project", - PROJECT_ID, - "--async", - "--region=us-central1", - "--quiet", - ], - check=True, - ) - + return token_str -def test_authentication_on_cloud_run(service: tuple[str, str]) -> None: - endpoint_url = service[0] - token = service[1] +@pytest.fixture(scope="module") +def client(endpoint_url: str) -> Session: req = request.Request(endpoint_url) try: _ = request.urlopen(req) except error.HTTPError as e: - assert e.code == HTTP_STATUS_FORBIDDEN + assert e.code == HTTPStatus.FORBIDDEN retry_strategy = Retry( total=3, @@ -145,65 +179,36 @@ def test_authentication_on_cloud_run(service: tuple[str, str]) -> None: client = requests.session() client.mount("https://", adapter) - response = client.get(endpoint_url, headers={"Authorization": f"Bearer {token}"}) - response_content = response.content.decode("UTF-8") - - assert response.status_code == HTTP_STATUS_OK - assert "Hello" in response_content - assert "anonymous" not in response_content - - -def test_anonymous_request_on_cloud_run(service: tuple[str, str]) -> None: - endpoint_url = service[0] + return client - req = request.Request(endpoint_url) - try: - _ = request.urlopen(req) - except error.HTTPError as e: - assert e.code == HTTP_STATUS_FORBIDDEN - retry_strategy = Retry( - total=3, - status_forcelist=STATUS_FORCELIST, - allowed_methods=["GET", "POST"], - backoff_factor=3, +def test_authentication_on_cloud_run( + client: Session, endpoint_url: str, token: str +) -> None: + response = client.get( + endpoint_url, headers={"Authorization": f"Bearer {token}"} ) - adapter = HTTPAdapter(max_retries=retry_strategy) + response_content = response.content.decode("utf-8") + + assert response.status_code == HTTPStatus.OK + assert "Hello" in response_content + assert "anonymous" not in response_content - client = requests.session() - client.mount("https://", adapter) +def test_anonymous_request_on_cloud_run(client: Session, endpoint_url: str) -> None: response = client.get(endpoint_url) - response_content = response.content.decode("UTF-8") + response_content = response.content.decode("utf-8") - assert response.status_code == HTTP_STATUS_OK + assert response.status_code == HTTPStatus.OK assert "Hello" in response_content assert "anonymous" in response_content -def test_invalid_token(service: tuple[str, str]) -> None: - endpoint_url = service[0] - - req = request.Request(endpoint_url) - try: - _ = request.urlopen(req) - except error.HTTPError as e: - assert e.code == HTTP_STATUS_FORBIDDEN - - retry_strategy = Retry( - total=3, - status_forcelist=STATUS_FORCELIST, - allowed_methods=["GET", "POST"], - backoff_factor=3, +def test_invalid_token(client: Session, endpoint_url: str, fake_token: str) -> None: + response = client.get( + endpoint_url, headers={"Authorization": f"Bearer {fake_token}"} ) - adapter = HTTPAdapter(max_retries=retry_strategy) - - client = requests.session() - client.mount("https://", adapter) - - # Sample token from https://jwt.io for John Doe. - token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" - response = client.get(endpoint_url, headers={"Authorization": f"Bearer {token}"}) - response_content = response.content.decode("UTF-8") + response_content = response.content.decode("utf-8") + assert response.status_code == HTTPStatus.UNAUTHORIZED assert "Invalid token" in response_content From 3c454d76bcf8b02b07e68f3a9faa6aabc5d40016 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Tue, 8 Apr 2025 17:03:37 -0600 Subject: [PATCH 11/11] fix(cloudrun): apply feedback from PR Review https://github.com/GoogleCloudPlatform/python-docs-samples/pull/13292#discussion_r2034096877 --- run/service-auth/data/privatekey.pem | 27 ------------------- run/service-auth/receive_test.py | 40 ++-------------------------- 2 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 run/service-auth/data/privatekey.pem diff --git a/run/service-auth/data/privatekey.pem b/run/service-auth/data/privatekey.pem deleted file mode 100644 index 2a00cd0088f..00000000000 --- a/run/service-auth/data/privatekey.pem +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj -7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/ -xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs -SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18 -pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk -SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk -nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq -HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y -nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9 -IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2 -YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU -Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ -vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP -B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl -aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2 -eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI -aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk -klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ -CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu -UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg -soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28 -bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH -504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL -YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx -BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg== ------END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/run/service-auth/receive_test.py b/run/service-auth/receive_test.py index 05e3e6d47b6..01d672e81ad 100644 --- a/run/service-auth/receive_test.py +++ b/run/service-auth/receive_test.py @@ -18,13 +18,9 @@ from http import HTTPStatus import os import subprocess -import time from urllib import error, request import uuid -from google.auth import crypt -from google.auth import jwt - import pytest import requests @@ -46,36 +42,6 @@ ], -DATA_DIR = os.path.join(os.path.dirname(__file__), "data") - -with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: - PRIVATE_KEY_BYTES = fh.read() - - -@pytest.fixture(scope="module") -def signer() -> crypt.RSASigner: - return crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, "1") - - -@pytest.fixture -def fake_token(signer: crypt.RSASigner) -> str: - now = int(time.time()) - payload = { - "aud": "example.com", - "azp": "1234567890", - "email": "example@example.iam.gserviceaccount.com", - "email_verified": True, - "iat": now, - "exp": now + 3600, - "iss": "https://accounts.google.com", - "sub": "1234567890", - } - header = {"alg": "RS256", "kid": signer.key_id, "typ": "JWT"} - token_str = jwt.encode(signer, payload, header=header).decode("utf-8") - - return token_str - - @pytest.fixture(scope="module") def service_name() -> str: # Add a unique suffix to create distinct service names. @@ -204,11 +170,9 @@ def test_anonymous_request_on_cloud_run(client: Session, endpoint_url: str) -> N assert "anonymous" in response_content -def test_invalid_token(client: Session, endpoint_url: str, fake_token: str) -> None: +def test_invalid_token(client: Session, endpoint_url: str) -> None: response = client.get( - endpoint_url, headers={"Authorization": f"Bearer {fake_token}"} + endpoint_url, headers={"Authorization": "Bearer i-am-not-a-real-token"} ) - response_content = response.content.decode("utf-8") assert response.status_code == HTTPStatus.UNAUTHORIZED - assert "Invalid token" in response_content