From 52b4ba59aa544e88bdcd3582e4ee50a4170542af Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 24 Mar 2020 17:20:18 -0700 Subject: [PATCH 1/6] feat: id_token adc with gcloud cred --- google/oauth2/id_token.py | 98 +++++++++++++++++++++++++++++ system_tests/test_compute_engine.py | 14 ++++- tests/oauth2/test_id_token.py | 97 ++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py index 1dbfb20ad..ac1260e70 100644 --- a/google/oauth2/id_token.py +++ b/google/oauth2/id_token.py @@ -59,12 +59,17 @@ """ import json +import os +import six from six.moves import http_client +from google.auth import _cloud_sdk +from google.auth import environment_vars from google.auth import exceptions from google.auth import jwt + # The URL that provides public certificates for verifying ID tokens issued # by Google's OAuth 2.0 authorization server. _GOOGLE_OAUTH2_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs" @@ -159,3 +164,96 @@ def verify_firebase_token(id_token, request, audience=None): return verify_token( id_token, request, audience=audience, certs_url=_GOOGLE_APIS_CERTS_URL ) + + +def fetch_id_token(request, audience): + """Fetch the ID Token from the current environment. + + This function acquires ID token from the environment in the following order: + + 1. If the application is running in Compute Engine, App Engine or Cloud Run, + then the ID token are obtained from the metadata server. + 2. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON file, then ID token is + acquired using this service account credentials. + 3. If Google Cloud SDK (gcloud) is installed and has application default + service account credentials set, then ID token is acquired using this + service account credentials. + 4. If metadata server doesn't exist and no valid service account credentials + are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will + be raised. + + Example:: + + import google.oauth2.id_token + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = google.oauth2.id_token.fetch_id_token(request, target_audience) + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + audience (str): The audience that this ID token is intended for. + + Returns: + str: The ID token. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If metadata server doesn't exist and no valid service account + credentials are found. + """ + # 1. First try to fetch ID token from metada server if it exists. The code + # works for GAE and cloud run metadata server as well. + try: + from google.auth import compute_engine + + credentials = compute_engine.IDTokenCredentials( + request, audience, use_metadata_identity_endpoint=True + ) + credentials.refresh(request) + return credentials.token + except (ImportError, exceptions.TransportError): + pass + + # 2. Try to use service account credentials to get ID token. + + # Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + # variable. + credentials_filename = os.environ.get(environment_vars.CREDENTIALS) + + # If GOOGLE_APPLICATION_CREDENTIALS environment variable doesn't exist, try + # to get from the Cloud SDK. + if not credentials_filename: + credentials_filename = _cloud_sdk.get_application_default_credentials_path() + + if ( + credentials_filename + and os.path.exists(credentials_filename) + and os.path.isfile(credentials_filename) + ): + with open(credentials_filename, "r") as f: + try: + info = json.load(f) + if info.get("type") == "service_account": + from google.oauth2 import service_account + + credentials = service_account.IDTokenCredentials.from_service_account_info( + info, target_audience=audience + ) + credentials.refresh(request) + return credentials.token + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "File {} is not a valid json file.".format(credentials_filename), + caught_exc, + ) + six.raise_from(new_exc, caught_exc) + + raise exceptions.DefaultCredentialsError( + "Failed to obtain ID token because metadata server and valid service " + "account credentials file don't exist." + ) diff --git a/system_tests/test_compute_engine.py b/system_tests/test_compute_engine.py index bcfdfd604..b0d42f362 100644 --- a/system_tests/test_compute_engine.py +++ b/system_tests/test_compute_engine.py @@ -20,6 +20,9 @@ from google.auth import exceptions from google.auth import jwt from google.auth.compute_engine import _metadata +import google.oauth2.id_token + +AUDIENCE = "https://pubsub.googleapis.com" @pytest.fixture(autouse=True) @@ -53,10 +56,17 @@ def test_default(verify_refresh): def test_id_token_from_metadata(http_request): credentials = compute_engine.IDTokenCredentials( - http_request, "target_audience", use_metadata_identity_endpoint=True + http_request, AUDIENCE, use_metadata_identity_endpoint=True ) credentials.refresh(http_request) _, payload, _, _ = jwt._unverified_decode(credentials.token) - assert payload["aud"] == "target_audience" + assert payload["aud"] == AUDIENCE assert payload["exp"] == credentials.expiry + + +def test_fetch_id_token(http_request): + token = google.oauth2.id_token.fetch_id_token(http_request, AUDIENCE) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == AUDIENCE diff --git a/tests/oauth2/test_id_token.py b/tests/oauth2/test_id_token.py index 980a8e982..546af323b 100644 --- a/tests/oauth2/test_id_token.py +++ b/tests/oauth2/test_id_token.py @@ -13,15 +13,23 @@ # limitations under the License. import json +import os import mock import pytest +from google.auth import environment_vars from google.auth import exceptions from google.auth import transport +import google.auth.compute_engine._metadata from google.oauth2 import id_token +SERVICE_ACCOUNT_FILE = os.path.join( + os.path.dirname(__file__), "../data/service_account.json" +) + + def make_request(status, data=None): response = mock.create_autospec(transport.Response, instance=True) response.status = status @@ -114,3 +122,92 @@ def test_verify_firebase_token(verify_token): audience=mock.sentinel.audience, certs_url=id_token._GOOGLE_APIS_CERTS_URL, ) + + +def test_fetch_id_token_from_metadata_server(): + def mock_init(self, request, audience, use_metadata_identity_endpoint): + assert use_metadata_identity_endpoint + self.token = "id_token" + + with mock.patch.multiple( + google.auth.compute_engine.IDTokenCredentials, + __init__=mock_init, + refresh=mock.Mock(), + ): + request = mock.Mock() + token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_from_explicit_cred_json_file(mock_init, monkeypatch): + monkeypatch.setenv(environment_vars.CREDENTIALS, SERVICE_ACCOUNT_FILE) + + def mock_refresh(self, request): + self.token = "id_token" + + with mock.patch.object( + google.oauth2.service_account.IDTokenCredentials, "refresh", mock_refresh + ): + request = mock.Mock() + token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", + return_value=SERVICE_ACCOUNT_FILE, +) +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_from_gcloud_cred_json_file( + mock_init, mock_sdk_adc_path, monkeypatch +): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + def mock_refresh(self, request): + self.token = "id_token" + + with mock.patch.object( + google.oauth2.service_account.IDTokenCredentials, "refresh", mock_refresh + ): + request = mock.Mock() + token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + assert token == "id_token" + + +@mock.patch( + "google.auth._cloud_sdk.get_application_default_credentials_path", return_value=None +) +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_no_cred_file(mock_init, mock_sdk_adc_path, monkeypatch): + monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.Mock() + id_token.fetch_id_token(request, "https://pubsub.googleapis.com") + + +@mock.patch.object( + google.auth.compute_engine.IDTokenCredentials, + "__init__", + side_effect=exceptions.TransportError(), +) +def test_fetch_id_token_invalid_cred_file(mock_init, monkeypatch): + no_json_file = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") + monkeypatch.setenv(environment_vars.CREDENTIALS, no_json_file) + + with pytest.raises(exceptions.DefaultCredentialsError): + request = mock.Mock() + id_token.fetch_id_token(request, "https://pubsub.googleapis.com") From cbe238b15d4f4e2f8d24b51906a1f8db9e7d5d93 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 24 Mar 2020 18:18:56 -0700 Subject: [PATCH 2/6] remove sdk approach --- google/oauth2/id_token.py | 59 +++++++++++++++-------------------- tests/oauth2/test_id_token.py | 35 ++------------------- 2 files changed, 29 insertions(+), 65 deletions(-) diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py index ac1260e70..746a6612b 100644 --- a/google/oauth2/id_token.py +++ b/google/oauth2/id_token.py @@ -64,7 +64,6 @@ import six from six.moves import http_client -from google.auth import _cloud_sdk from google.auth import environment_vars from google.auth import exceptions from google.auth import jwt @@ -176,10 +175,7 @@ def fetch_id_token(request, audience): 2. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set to the path of a valid service account JSON file, then ID token is acquired using this service account credentials. - 3. If Google Cloud SDK (gcloud) is installed and has application default - service account credentials set, then ID token is acquired using this - service account credentials. - 4. If metadata server doesn't exist and no valid service account credentials + 3. If metadata server doesn't exist and no valid service account credentials are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. @@ -224,36 +220,33 @@ def fetch_id_token(request, audience): # Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment # variable. credentials_filename = os.environ.get(environment_vars.CREDENTIALS) - - # If GOOGLE_APPLICATION_CREDENTIALS environment variable doesn't exist, try - # to get from the Cloud SDK. - if not credentials_filename: - credentials_filename = _cloud_sdk.get_application_default_credentials_path() - - if ( + if not ( credentials_filename and os.path.exists(credentials_filename) and os.path.isfile(credentials_filename) ): + raise exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found." + ) + + try: with open(credentials_filename, "r") as f: - try: - info = json.load(f) - if info.get("type") == "service_account": - from google.oauth2 import service_account - - credentials = service_account.IDTokenCredentials.from_service_account_info( - info, target_audience=audience - ) - credentials.refresh(request) - return credentials.token - except ValueError as caught_exc: - new_exc = exceptions.DefaultCredentialsError( - "File {} is not a valid json file.".format(credentials_filename), - caught_exc, - ) - six.raise_from(new_exc, caught_exc) - - raise exceptions.DefaultCredentialsError( - "Failed to obtain ID token because metadata server and valid service " - "account credentials file don't exist." - ) + info = json.load(f) + credentials_content = ( + (info.get("type") == "service_account") and info or None + ) + + from google.oauth2 import service_account + + credentials = service_account.IDTokenCredentials.from_service_account_info( + credentials_content, target_audience=audience + ) + except ValueError as caught_exc: + new_exc = exceptions.DefaultCredentialsError( + "Neither metadata server or valid service account credentials are found.", + caught_exc, + ) + six.raise_from(new_exc, caught_exc) + + credentials.refresh(request) + return credentials.token diff --git a/tests/oauth2/test_id_token.py b/tests/oauth2/test_id_token.py index 546af323b..ff858078a 100644 --- a/tests/oauth2/test_id_token.py +++ b/tests/oauth2/test_id_token.py @@ -24,7 +24,6 @@ import google.auth.compute_engine._metadata from google.oauth2 import id_token - SERVICE_ACCOUNT_FILE = os.path.join( os.path.dirname(__file__), "../data/service_account.json" ) @@ -158,40 +157,12 @@ def mock_refresh(self, request): assert token == "id_token" -@mock.patch( - "google.auth._cloud_sdk.get_application_default_credentials_path", - return_value=SERVICE_ACCOUNT_FILE, -) -@mock.patch.object( - google.auth.compute_engine.IDTokenCredentials, - "__init__", - side_effect=exceptions.TransportError(), -) -def test_fetch_id_token_from_gcloud_cred_json_file( - mock_init, mock_sdk_adc_path, monkeypatch -): - monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) - - def mock_refresh(self, request): - self.token = "id_token" - - with mock.patch.object( - google.oauth2.service_account.IDTokenCredentials, "refresh", mock_refresh - ): - request = mock.Mock() - token = id_token.fetch_id_token(request, "https://pubsub.googleapis.com") - assert token == "id_token" - - -@mock.patch( - "google.auth._cloud_sdk.get_application_default_credentials_path", return_value=None -) @mock.patch.object( google.auth.compute_engine.IDTokenCredentials, "__init__", side_effect=exceptions.TransportError(), ) -def test_fetch_id_token_no_cred_file(mock_init, mock_sdk_adc_path, monkeypatch): +def test_fetch_id_token_no_cred_json_file(mock_init, monkeypatch): monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) with pytest.raises(exceptions.DefaultCredentialsError): @@ -205,8 +176,8 @@ def test_fetch_id_token_no_cred_file(mock_init, mock_sdk_adc_path, monkeypatch): side_effect=exceptions.TransportError(), ) def test_fetch_id_token_invalid_cred_file(mock_init, monkeypatch): - no_json_file = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") - monkeypatch.setenv(environment_vars.CREDENTIALS, no_json_file) + not_json_file = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") + monkeypatch.setenv(environment_vars.CREDENTIALS, not_json_file) with pytest.raises(exceptions.DefaultCredentialsError): request = mock.Mock() From 665a831dc88c034b09accdc4576a1f1f74cda509 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Tue, 24 Mar 2020 18:32:05 -0700 Subject: [PATCH 3/6] add system test --- system_tests/noxfile.py | 2 +- system_tests/test_compute_engine.py | 7 +++---- system_tests/test_id_token.py | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 system_tests/test_id_token.py diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index 6e66eb4ed..14cd3db8e 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -200,7 +200,7 @@ def default_explicit_service_account(session): session.env[EXPECT_PROJECT_ENV] = "1" session.install(*TEST_DEPENDENCIES) session.install(LIBRARY_DIR) - session.run("pytest", "test_default.py") + session.run("pytest", "test_default.py", "test_id_token.py") @nox.session(python=PYTHON_VERSIONS) diff --git a/system_tests/test_compute_engine.py b/system_tests/test_compute_engine.py index b0d42f362..d2d68a00a 100644 --- a/system_tests/test_compute_engine.py +++ b/system_tests/test_compute_engine.py @@ -22,8 +22,6 @@ from google.auth.compute_engine import _metadata import google.oauth2.id_token -AUDIENCE = "https://pubsub.googleapis.com" - @pytest.fixture(autouse=True) def check_gce_environment(http_request): @@ -66,7 +64,8 @@ def test_id_token_from_metadata(http_request): def test_fetch_id_token(http_request): - token = google.oauth2.id_token.fetch_id_token(http_request, AUDIENCE) + audience = "https://pubsub.googleapis.com" + token = google.oauth2.id_token.fetch_id_token(http_request, audience) _, payload, _, _ = jwt._unverified_decode(token) - assert payload["aud"] == AUDIENCE + assert payload["aud"] == audience diff --git a/system_tests/test_id_token.py b/system_tests/test_id_token.py new file mode 100644 index 000000000..b07cefc18 --- /dev/null +++ b/system_tests/test_id_token.py @@ -0,0 +1,25 @@ +# Copyright 2020 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. +import pytest + +from google.auth import jwt +import google.oauth2.id_token + + +def test_fetch_id_token(http_request): + audience = "https://pubsub.googleapis.com" + token = google.oauth2.id_token.fetch_id_token(http_request, audience) + + _, payload, _, _ = jwt._unverified_decode(token) + assert payload["aud"] == audience From d430dbe70a07029af260da0de4abfab13accc0aa Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Wed, 25 Mar 2020 09:45:52 -0700 Subject: [PATCH 4/6] fix sys test --- system_tests/test_compute_engine.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/system_tests/test_compute_engine.py b/system_tests/test_compute_engine.py index d2d68a00a..b0d42f362 100644 --- a/system_tests/test_compute_engine.py +++ b/system_tests/test_compute_engine.py @@ -22,6 +22,8 @@ from google.auth.compute_engine import _metadata import google.oauth2.id_token +AUDIENCE = "https://pubsub.googleapis.com" + @pytest.fixture(autouse=True) def check_gce_environment(http_request): @@ -64,8 +66,7 @@ def test_id_token_from_metadata(http_request): def test_fetch_id_token(http_request): - audience = "https://pubsub.googleapis.com" - token = google.oauth2.id_token.fetch_id_token(http_request, audience) + token = google.oauth2.id_token.fetch_id_token(http_request, AUDIENCE) _, payload, _, _ = jwt._unverified_decode(token) - assert payload["aud"] == audience + assert payload["aud"] == AUDIENCE From 973229f3a0b25eba6c38a7218aad42e35f419e51 Mon Sep 17 00:00:00 2001 From: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com> Date: Wed, 25 Mar 2020 11:07:43 -0700 Subject: [PATCH 5/6] Update google/oauth2/id_token.py Co-Authored-By: Bu Sun Kim <8822365+busunkim96@users.noreply.github.com> --- google/oauth2/id_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/oauth2/id_token.py b/google/oauth2/id_token.py index 746a6612b..f559c6c39 100644 --- a/google/oauth2/id_token.py +++ b/google/oauth2/id_token.py @@ -203,7 +203,7 @@ def fetch_id_token(request, audience): credentials are found. """ # 1. First try to fetch ID token from metada server if it exists. The code - # works for GAE and cloud run metadata server as well. + # works for GAE and Cloud Run metadata server as well. try: from google.auth import compute_engine From b145c2c674effa58907e9491d8ade78e84f53c37 Mon Sep 17 00:00:00 2001 From: Sijun Liu Date: Mon, 30 Mar 2020 11:54:12 -0700 Subject: [PATCH 6/6] update doc --- docs/user-guide.rst | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 0abe160a3..579614680 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -291,7 +291,21 @@ Impersonated :: target_credentials, target_audience=target_audience) -IDToken verification can be done for various type of IDTokens using the :class:`google.oauth2.id_token` module +If your application runs on `App Engine`_, `Cloud Run`_, `Compute Engine`_, or +has application default credentials set via `GOOGLE_APPLICATION_CREDENTIALS` +environment variable, you can also use `google.oauth2.id_token.fetch_id_token` +to obtain an ID token from your current running environment. The following is an +example :: + + import google.oauth2.id_token + import google.auth.transport.requests + + request = google.auth.transport.requests.Request() + target_audience = "https://pubsub.googleapis.com" + + id_token = google.oauth2.id_token.fetch_id_token(request, target_audience) + +IDToken verification can be done for various type of IDTokens using the :class:`google.oauth2.id_token` module A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe :: @@ -320,8 +334,10 @@ A sample end-to-end flow using an ID Token against a Cloud Run endpoint maybe :: print(token) print(id_token.verify_token(token,request)) +.. _App Engine: https://cloud.google.com/appengine/ .. _Cloud Functions: https://cloud.google.com/functions/ .. _Cloud Run: https://cloud.google.com/run/ +.. _Compute Engine: https://cloud.google.com/compute/ .. _Identity Aware Proxy: https://cloud.google.com/iap/ .. _Google OpenID Connect: https://developers.google.com/identity/protocols/OpenIDConnect .. _Google ID Token: https://developers.google.com/identity/protocols/OpenIDConnect#validatinganidtoken