Skip to content

Commit 562b7bc

Browse files
fix(cloudrun): fix 'cloudrun_service_to_service_receive' sample (#13372)
* fix(cloudrun): Proof of Concept - Service to Service. Implement a Flask app which receives ID tokens, and returns an HTTP response based on the validation of the token. * fix(cloudrun): Proof of Concept - fix sample to pass Unit Tests * fix(cloudrun): fixes to pass unit tests * fix(cloudrun): delete draft * fix(cloudrun): add feedback from code-assist - Add a try-except block to handle potential ValueError. * fix(cloudrun): delete placeholder variable * fix(cloudrun): fix typo * fix(cloudrun): PoC Replace gcloud commands with a Client Library * fix(cloudrun): clean up before sharing for internal review * fix(cloudrun): delete proof of concept to replace gcloud commands with a client library * Revert "fix(cloudrun): delete proof of concept to replace gcloud commands with a client library" This reverts commit 4ddb193. * Revert "fix(cloudrun): clean up before sharing for internal review" This reverts commit a8486ba. * Revert "fix(cloudrun): PoC Replace gcloud commands with a Client Library" This reverts commit 48f54a7. * fix(cloudrun): rename 'test_invalid_token' PR Review #13372 (comment) * fix(cloudrun): apply feedback from PR Review #13372 (review) - Change from getting the service URL from the Metadata server to an env var supplied in the Deployment - Fix comments - Change from getting the URL from the `gcloud run services descrive` to the same Client library used in app.py to unify the URL - Rename tests * fix(cloudrun): remove duplicated code to get the Service URL * fix(cloudrun): code cleanup before review * fix(cloudrun): implement reading the Service URL from an env var #13372 (comment) * fix(cloudrun): directly return the id token in the fixture * fix(cloudrun): delete unused import
1 parent 800c162 commit 562b7bc

File tree

5 files changed

+140
-74
lines changed

5 files changed

+140
-74
lines changed

run/service-auth/app.py

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,104 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
# [START auth_validate_and_decode_bearer_token_on_flask]
16+
# [START cloudrun_service_to_service_receive]
17+
"""Demonstrates how to receive authenticated service-to-service requests
18+
on a Cloud Run Service.
19+
"""
20+
1521
from http import HTTPStatus
1622
import os
23+
from typing import Optional
1724

1825
from flask import Flask, request
1926

20-
from receive import receive_request_and_parse_auth_header
27+
from google.auth.exceptions import GoogleAuthError
28+
from google.auth.transport import requests
29+
from google.oauth2 import id_token
2130

2231
app = Flask(__name__)
2332

2433

34+
def parse_auth_header(auth_header: str) -> Optional[str]:
35+
"""Parse the authorization header, validate and decode the Bearer token.
36+
37+
Args:
38+
auth_header: Raw HTTP header with a Bearer token.
39+
40+
Returns:
41+
A string containing the email from the token.
42+
None if the token is invalid or the email can't be retrieved.
43+
"""
44+
45+
# Split the auth type and value from the header.
46+
try:
47+
auth_type, creds = auth_header.split(" ", 1)
48+
except ValueError:
49+
print("Malformed Authorization header.")
50+
return None
51+
52+
# Get the service URL from the environment variable
53+
# set at the time of deployment.
54+
service_url = os.environ["SERVICE_URL"]
55+
56+
# Define the expected audience as the Service Base URL.
57+
audience = service_url
58+
59+
# Validate and decode the ID token in the header.
60+
if auth_type.lower() == "bearer":
61+
try:
62+
# Find more information about `verify_oauth2_token` function:
63+
# https://googleapis.dev/python/google-auth/latest/reference/google.oauth2.id_token.html#google.oauth2.id_token.verify_oauth2_token
64+
decoded_token = id_token.verify_oauth2_token(
65+
id_token=creds,
66+
request=requests.Request(),
67+
audience=audience,
68+
)
69+
70+
# More info about the structure for the decoded ID Token here:
71+
# https://cloud.google.com/docs/authentication/token-types#id
72+
73+
# Verify that the token contains the email claim.
74+
if decoded_token['email_verified']:
75+
print(f"Email verified: {decoded_token['email']}")
76+
77+
return decoded_token['email']
78+
79+
print("Invalid token. Email wasn't verified.")
80+
except GoogleAuthError as e:
81+
print(f"Invalid token: {e}")
82+
else:
83+
print(f"Unhandled header format ({auth_type}).")
84+
85+
return None
86+
87+
2588
@app.route("/")
2689
def main() -> str:
27-
"""Example route for receiving authorized requests."""
90+
"""Example route for receiving authorized requests only."""
2891
try:
29-
response = receive_request_and_parse_auth_header(request)
92+
auth_header = request.headers.get("Authorization")
93+
if auth_header:
94+
email = parse_auth_header(auth_header)
95+
96+
if email:
97+
return f"Hello, {email}.\n", HTTPStatus.OK
3098

31-
status = HTTPStatus.UNAUTHORIZED
32-
if "Hello" in response:
33-
status = HTTPStatus.OK
99+
# Indicate that the request must be authenticated
100+
# and that Bearer auth is the permitted authentication scheme.
101+
headers = {"WWW-Authenticate": "Bearer"}
34102

35-
return response, status
103+
return (
104+
"Unauthorized request. Please supply a valid bearer token.",
105+
HTTPStatus.UNAUTHORIZED,
106+
headers,
107+
)
36108
except Exception as e:
37109
return f"Error verifying ID token: {e}", HTTPStatus.UNAUTHORIZED
38110

39111

40112
if __name__ == "__main__":
41113
app.run(host="localhost", port=int(os.environ.get("PORT", 8080)), debug=True)
114+
# [END cloudrun_service_to_service_receive]
115+
# [END auth_validate_and_decode_bearer_token_on_flask]

run/service-auth/receive.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
For example for Cloud Run or Cloud Functions.
1818
"""
1919

20+
# This sample will be migrated to app.py
21+
2022
# [START auth_validate_and_decode_bearer_token_on_flask]
2123
# [START cloudrun_service_to_service_receive]
2224
from flask import Request

run/service-auth/receive_test.py renamed to run/service-auth/receive_auth_requests_test.py

Lines changed: 54 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
# limitations under the License.
1414

1515
# This test deploys a secure application running on Cloud Run
16-
# to test that the authentication sample works properly.
16+
# to validate receiving authenticated requests.
1717

1818
from http import HTTPStatus
1919
import os
2020
import subprocess
21-
from urllib import error, request
2221
import uuid
2322

23+
import backoff
24+
25+
from google.auth.transport import requests as transport_requests
26+
from google.oauth2 import id_token
27+
2428
import pytest
2529

2630
import requests
@@ -29,6 +33,7 @@
2933
from requests.sessions import Session
3034

3135
PROJECT_ID = os.environ["GOOGLE_CLOUD_PROJECT"]
36+
REGION = "us-central1"
3237

3338
STATUS_FORCELIST = [
3439
HTTPStatus.BAD_REQUEST,
@@ -43,30 +48,55 @@
4348

4449

4550
@pytest.fixture(scope="module")
46-
def service_name() -> str:
51+
def project_number() -> str:
52+
return (
53+
subprocess.run(
54+
[
55+
"gcloud",
56+
"projects",
57+
"describe",
58+
PROJECT_ID,
59+
"--format=value(projectNumber)",
60+
],
61+
stdout=subprocess.PIPE,
62+
check=True,
63+
)
64+
.stdout.strip()
65+
.decode()
66+
)
67+
68+
69+
@pytest.fixture(scope="module")
70+
def service_url(project_number: str) -> str:
71+
"""Deploys a Run Service and returns its Base URL."""
72+
4773
# Add a unique suffix to create distinct service names.
48-
service_name_str = f"receive-{uuid.uuid4().hex}"
74+
service_name = f"receive-python-{uuid.uuid4().hex}"
4975

50-
# Deploy the Cloud Run Service.
76+
# Construct the Deterministic URL.
77+
service_url = f"https://{service_name}-{project_number}.{REGION}.run.app"
78+
79+
# Deploy the Cloud Run Service supplying the URL as an environment variable.
5180
subprocess.run(
5281
[
5382
"gcloud",
5483
"run",
5584
"deploy",
56-
service_name_str,
85+
service_name,
5786
"--project",
5887
PROJECT_ID,
5988
"--source",
6089
".",
61-
"--region=us-central1",
90+
f"--region={REGION}",
6291
"--allow-unauthenticated",
92+
f"--set-env-vars=SERVICE_URL={service_url}",
6393
"--quiet",
6494
],
6595
# Rise a CalledProcessError exception for a non-zero exit code.
6696
check=True,
6797
)
6898

69-
yield service_name_str
99+
yield service_url
70100

71101
# Clean-up after running the test.
72102
subprocess.run(
@@ -75,65 +105,27 @@ def service_name() -> str:
75105
"run",
76106
"services",
77107
"delete",
78-
service_name_str,
108+
service_name,
79109
"--project",
80110
PROJECT_ID,
81111
"--async",
82-
"--region=us-central1",
112+
f"--region={REGION}",
83113
"--quiet",
84114
],
85115
check=True,
86116
)
87117

88118

89119
@pytest.fixture(scope="module")
90-
def endpoint_url(service_name: str) -> str:
91-
endpoint_url_str = (
92-
subprocess.run(
93-
[
94-
"gcloud",
95-
"run",
96-
"services",
97-
"describe",
98-
service_name,
99-
"--project",
100-
PROJECT_ID,
101-
"--region=us-central1",
102-
"--format=value(status.url)",
103-
],
104-
stdout=subprocess.PIPE,
105-
check=True,
106-
)
107-
.stdout.strip()
108-
.decode()
109-
)
120+
def token(service_url: str) -> str:
121+
auth_req = transport_requests.Request()
122+
target_audience = service_url
110123

111-
return endpoint_url_str
124+
return id_token.fetch_id_token(auth_req, target_audience)
112125

113126

114127
@pytest.fixture(scope="module")
115-
def token() -> str:
116-
token_str = (
117-
subprocess.run(
118-
["gcloud", "auth", "print-identity-token"],
119-
stdout=subprocess.PIPE,
120-
check=True,
121-
)
122-
.stdout.strip()
123-
.decode()
124-
)
125-
126-
return token_str
127-
128-
129-
@pytest.fixture(scope="module")
130-
def client(endpoint_url: str) -> Session:
131-
req = request.Request(endpoint_url)
132-
try:
133-
_ = request.urlopen(req)
134-
except error.HTTPError as e:
135-
assert e.code == HTTPStatus.FORBIDDEN
136-
128+
def client() -> Session:
137129
retry_strategy = Retry(
138130
total=3,
139131
status_forcelist=STATUS_FORCELIST,
@@ -148,31 +140,28 @@ def client(endpoint_url: str) -> Session:
148140
return client
149141

150142

151-
def test_authentication_on_cloud_run(
152-
client: Session, endpoint_url: str, token: str
143+
@backoff.on_exception(backoff.expo, Exception, max_time=60)
144+
def test_authenticated_request(
145+
client: Session, service_url: str, token: str,
153146
) -> None:
154147
response = client.get(
155-
endpoint_url, headers={"Authorization": f"Bearer {token}"}
148+
service_url, headers={"Authorization": f"Bearer {token}"}
156149
)
157150
response_content = response.content.decode("utf-8")
158151

159152
assert response.status_code == HTTPStatus.OK
160153
assert "Hello" in response_content
161-
assert "anonymous" not in response_content
162154

163155

164-
def test_anonymous_request_on_cloud_run(client: Session, endpoint_url: str) -> None:
165-
response = client.get(endpoint_url)
166-
response_content = response.content.decode("utf-8")
156+
def test_anonymous_request(client: Session, service_url: str) -> None:
157+
response = client.get(service_url)
167158

168-
assert response.status_code == HTTPStatus.OK
169-
assert "Hello" in response_content
170-
assert "anonymous" in response_content
159+
assert response.status_code == HTTPStatus.UNAUTHORIZED
171160

172161

173-
def test_invalid_token(client: Session, endpoint_url: str) -> None:
162+
def test_invalid_token(client: Session, service_url: str) -> None:
174163
response = client.get(
175-
endpoint_url, headers={"Authorization": "Bearer i-am-not-a-real-token"}
164+
service_url, headers={"Authorization": "Bearer i-am-not-a-real-token"}
176165
)
177166

178167
assert response.status_code == HTTPStatus.UNAUTHORIZED
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pytest==8.3.5
2+
backoff==2.2.1

run/service-auth/requirements.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
google-auth==2.38.0
1+
google-auth==2.40.1
2+
google-cloud-run==0.10.18
23
requests==2.32.3
34
Flask==3.1.1
45
gunicorn==23.0.0
5-
Werkzeug==3.1.3

0 commit comments

Comments
 (0)