diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 5fc5daa31..b8edda51c 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,4 +13,4 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:8555f0e37e6261408f792bfd6635102d2da5ad73f8f09bcb24f25e6afb5fac97 + digest: sha256:2e247c7bf5154df7f98cce087a20ca7605e236340c7d6d1a14447e5c06791bd6 diff --git a/.kokoro/requirements.in b/.kokoro/requirements.in index 882178ce6..ec867d9fd 100644 --- a/.kokoro/requirements.in +++ b/.kokoro/requirements.in @@ -5,6 +5,6 @@ typing-extensions twine wheel setuptools -nox +nox>=2022.11.21 # required to remove dependency on py charset-normalizer<3 click<8.1.0 diff --git a/.kokoro/requirements.txt b/.kokoro/requirements.txt index fa99c1290..66a2172a7 100644 --- a/.kokoro/requirements.txt +++ b/.kokoro/requirements.txt @@ -1,6 +1,6 @@ # -# This file is autogenerated by pip-compile with python 3.10 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: # # pip-compile --allow-unsafe --generate-hashes requirements.in # @@ -335,9 +335,9 @@ more-itertools==9.0.0 \ --hash=sha256:250e83d7e81d0c87ca6bd942e6aeab8cc9daa6096d12c5308f3f92fa5e5c1f41 \ --hash=sha256:5a6257e40878ef0520b1803990e3e22303a41b5714006c32a3fd8304b26ea1ab # via jaraco-classes -nox==2022.8.7 \ - --hash=sha256:1b894940551dc5c389f9271d197ca5d655d40bdc6ccf93ed6880e4042760a34b \ - --hash=sha256:96cca88779e08282a699d672258ec01eb7c792d35bbbf538c723172bce23212c +nox==2022.11.21 \ + --hash=sha256:0e41a990e290e274cb205a976c4c97ee3c5234441a8132c8c3fd9ea3c22149eb \ + --hash=sha256:e21c31de0711d1274ca585a2c5fde36b1aa962005ba8e9322bf5eeed16dcd684 # via -r requirements.in packaging==21.3 \ --hash=sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb \ @@ -380,10 +380,6 @@ protobuf==3.20.3 \ # gcp-docuploader # gcp-releasetool # google-api-core -py==1.11.0 \ - --hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \ - --hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378 - # via nox pyasn1==0.4.8 \ --hash=sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d \ --hash=sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba diff --git a/CHANGELOG.md b/CHANGELOG.md index c8d64758c..5fb35c8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.17.0](https://github.com/googleapis/google-auth-library-python/compare/v2.16.3...v2.17.0) (2023-03-28) + + +### Features + +* Experimental service account iam endpoint flow for id token ([#1258](https://github.com/googleapis/google-auth-library-python/issues/1258)) ([8ff0de5](https://github.com/googleapis/google-auth-library-python/commit/8ff0de5f6c26c8778e24e57d6b7f449856357f81)) + + +### Bug Fixes + +* Python: Remove aws url validation ([#1254](https://github.com/googleapis/google-auth-library-python/issues/1254)) ([20a966b](https://github.com/googleapis/google-auth-library-python/commit/20a966bbbfc66932f471e0bfd191769f40332233)) + ## [2.16.3](https://github.com/googleapis/google-auth-library-python/compare/v2.16.2...v2.16.3) (2023-03-24) diff --git a/google/auth/aws.py b/google/auth/aws.py index f651433f0..13644c4c2 100644 --- a/google/auth/aws.py +++ b/google/auth/aws.py @@ -47,7 +47,6 @@ from six.moves import http_client from six.moves import urllib from six.moves.urllib.parse import urljoin -from six.moves.urllib.parse import urlparse from google.auth import _helpers from google.auth import environment_vars @@ -398,8 +397,6 @@ def __init__( self._request_signer = None self._target_resource = audience - self.validate_metadata_server_urls() - # Get the environment ID. Currently, only one version supported (v1). matches = re.match(r"^(aws)([\d]+)$", self._environment_id) if matches: @@ -418,22 +415,6 @@ def __init__( ) ) - def validate_metadata_server_urls(self): - self.validate_metadata_server_url_if_any(self._region_url, "region_url") - self.validate_metadata_server_url_if_any(self._security_credentials_url, "url") - self.validate_metadata_server_url_if_any( - self._imdsv2_session_token_url, "imdsv2_session_token_url" - ) - - @staticmethod - def validate_metadata_server_url_if_any(url_string, name_of_data): - if url_string: - url = urlparse(url_string) - if url.hostname != "169.254.169.254" and url.hostname != "fd00:ec2::254": - raise exceptions.InvalidResource( - "Invalid hostname '{}' for '{}'".format(url.hostname, name_of_data) - ) - def retrieve_subject_token(self, request): """Retrieves the subject token using the credential_source object. The subject token is a serialized `AWS GetCallerIdentity signed request`_. diff --git a/google/auth/version.py b/google/auth/version.py index e239d71c9..0e7fb575a 100644 --- a/google/auth/version.py +++ b/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.16.3" +__version__ = "2.17.0" diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index 428993646..74e769fa1 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -40,6 +40,10 @@ _JSON_CONTENT_TYPE = "application/json" _JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" _REFRESH_GRANT_TYPE = "refresh_token" +_IAM_IDTOKEN_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/" + + "projects/-/serviceAccounts/{}:generateIdToken" +) def _handle_error_response(response_data, retryable_error): @@ -313,6 +317,44 @@ def jwt_grant(request, token_uri, assertion, can_retry=True): return access_token, expiry, response_data +def call_iam_generate_id_token_endpoint(request, signer_email, audience, access_token): + """Call iam.generateIdToken endpoint to get ID token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + signer_email (str): The signer email used to form the IAM + generateIdToken endpoint. + audience (str): The audience for the ID token. + access_token (str): The access token used to call the IAM endpoint. + + Returns: + Tuple[str, datetime]: The ID token and expiration. + """ + body = {"audience": audience, "includeEmail": "true"} + + response_data = _token_endpoint_request( + request, + _IAM_IDTOKEN_ENDPOINT.format(signer_email), + body, + access_token=access_token, + use_json=True, + ) + + try: + id_token = response_data["token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError( + "No ID token in response.", response_data, retryable=False + ) + six.raise_from(new_exc, caught_exc) + + payload = jwt.decode(id_token, verify=False) + expiry = datetime.datetime.utcfromtimestamp(payload["exp"]) + + return id_token, expiry + + def id_token_jwt_grant(request, token_uri, assertion, can_retry=True): """Implements the JWT Profile for OAuth 2.0 Authorization Grants, but requests an OpenID Connect ID Token instead of an access token. diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 0989750db..618ab538b 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -554,6 +554,7 @@ def __init__( self._token_uri = token_uri self._target_audience = target_audience self._quota_project_id = quota_project_id + self._use_iam_endpoint = False if additional_claims is not None: self._additional_claims = additional_claims @@ -639,6 +640,31 @@ def with_target_audience(self, target_audience): quota_project_id=self.quota_project_id, ) + def _with_use_iam_endpoint(self, use_iam_endpoint): + """Create a copy of these credentials with the use_iam_endpoint value. + + Args: + use_iam_endpoint (bool): If True, IAM generateIdToken endpoint will + be used instead of the token_uri. Note that + iam.serviceAccountTokenCreator role is required to use the IAM + endpoint. The default value is False. This feature is currently + experimental and subject to change without notice. + + Returns: + google.auth.service_account.IDTokenCredentials: A new credentials + instance. + """ + cred = self.__class__( + self._signer, + service_account_email=self._service_account_email, + token_uri=self._token_uri, + target_audience=self._target_audience, + additional_claims=self._additional_claims.copy(), + quota_project_id=self.quota_project_id, + ) + cred._use_iam_endpoint = use_iam_endpoint + return cred + @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): return self.__class__( @@ -692,14 +718,50 @@ def _make_authorization_grant_assertion(self): return token + def _refresh_with_iam_endpoint(self, request): + """Use IAM generateIdToken endpoint to obtain an ID token. + + It works as follows: + + 1. First we create a self signed jwt with + https://www.googleapis.com/auth/iam being the scope. + + 2. Next we use the self signed jwt as the access token, and make a POST + request to IAM generateIdToken endpoint. The request body is: + { + "audience": self._target_audience, + "includeEmail": "true" + } + TODO: add "set_azp_to_email": "true" once it's ready from server side. + https://github.com/googleapis/google-auth-library-python/issues/1263 + + If the request is succesfully, it will return {"token":"the ID token"}, + and we can extract the ID token and compute its expiry. + """ + jwt_credentials = jwt.Credentials.from_signing_credentials( + self, + None, + additional_claims={"scope": "https://www.googleapis.com/auth/iam"}, + ) + jwt_credentials.refresh(request) + self.token, self.expiry = _client.call_iam_generate_id_token_endpoint( + request, + self.signer_email, + self._target_audience, + jwt_credentials.token.decode(), + ) + @_helpers.copy_docstring(credentials.Credentials) def refresh(self, request): - assertion = self._make_authorization_grant_assertion() - access_token, expiry, _ = _client.id_token_jwt_grant( - request, self._token_uri, assertion - ) - self.token = access_token - self.expiry = expiry + if self._use_iam_endpoint: + self._refresh_with_iam_endpoint(request) + else: + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = _client.id_token_jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry @property def service_account_email(self): diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 5cff634c1..06bd272d0 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index ff3096057..4997d2401 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -305,6 +305,50 @@ def test_jwt_grant_no_access_token(): assert not excinfo.value.retryable +def test_call_iam_generate_id_token_endpoint(): + now = _helpers.utcnow() + id_token_expiry = _helpers.datetime_to_secs(now) + id_token = jwt.encode(SIGNER, {"exp": id_token_expiry}).decode("utf-8") + request = make_request({"token": id_token}) + + token, expiry = _client.call_iam_generate_id_token_endpoint( + request, "fake_email", "fake_audience", "fake_access_token" + ) + + assert ( + request.call_args[1]["url"] + == "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/fake_email:generateIdToken" + ) + assert request.call_args[1]["headers"]["Content-Type"] == "application/json" + assert ( + request.call_args[1]["headers"]["Authorization"] == "Bearer fake_access_token" + ) + response_body = json.loads(request.call_args[1]["body"]) + assert response_body["audience"] == "fake_audience" + assert response_body["includeEmail"] == "true" + + # Check result + assert token == id_token + # JWT does not store microseconds + now = now.replace(microsecond=0) + assert expiry == now + + +def test_call_iam_generate_id_token_endpoint_no_id_token(): + request = make_request( + { + # No access token. + "error": "no token" + } + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _client.call_iam_generate_id_token_endpoint( + request, "fake_email", "fake_audience", "fake_access_token" + ) + assert excinfo.match("No ID token in response") + + def test_id_token_jwt_grant(): now = _helpers.utcnow() id_token_expiry = _helpers.datetime_to_secs(now) diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index ed281fcfa..741027973 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -428,6 +428,7 @@ def test_from_service_account_info(self): assert credentials.service_account_email == SERVICE_ACCOUNT_INFO["client_email"] assert credentials._token_uri == SERVICE_ACCOUNT_INFO["token_uri"] assert credentials._target_audience == self.TARGET_AUDIENCE + assert not credentials._use_iam_endpoint def test_from_service_account_file(self): info = SERVICE_ACCOUNT_INFO.copy() @@ -440,6 +441,7 @@ def test_from_service_account_file(self): assert credentials._signer.key_id == info["private_key_id"] assert credentials._token_uri == info["token_uri"] assert credentials._target_audience == self.TARGET_AUDIENCE + assert not credentials._use_iam_endpoint def test_default_state(self): credentials = self.make_credentials() @@ -466,6 +468,11 @@ def test_with_target_audience(self): new_credentials = credentials.with_target_audience("https://new.example.com") assert new_credentials._target_audience == "https://new.example.com" + def test__with_use_iam_endpoint(self): + credentials = self.make_credentials() + new_credentials = credentials._with_use_iam_endpoint(True) + assert new_credentials._use_iam_endpoint + def test_with_quota_project(self): credentials = self.make_credentials() new_credentials = credentials.with_quota_project("project-foo") @@ -517,6 +524,28 @@ def test_refresh_success(self, id_token_jwt_grant): # expired) assert credentials.valid + @mock.patch( + "google.oauth2._client.call_iam_generate_id_token_endpoint", autospec=True + ) + def test_refresh_iam_flow(self, call_iam_generate_id_token_endpoint): + credentials = self.make_credentials() + credentials._use_iam_endpoint = True + token = "id_token" + call_iam_generate_id_token_endpoint.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + ) + request = mock.Mock() + credentials.refresh(request) + req, signer_email, target_audience, access_token = call_iam_generate_id_token_endpoint.call_args[ + 0 + ] + assert req == request + assert signer_email == "service-account@example.com" + assert target_audience == "https://example.com" + decoded_access_token = jwt.decode(access_token, verify=False) + assert decoded_access_token["scope"] == "https://www.googleapis.com/auth/iam" + @mock.patch("google.oauth2._client.id_token_jwt_grant", autospec=True) def test_before_request_refreshes(self, id_token_jwt_grant): credentials = self.make_credentials() diff --git a/tests/test_aws.py b/tests/test_aws.py index 7d87bdba2..805aa3ce2 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -1495,39 +1495,6 @@ def test_retrieve_subject_token_success_temp_creds_idmsv2(self, utcnow): credentials.retrieve_subject_token(request) assert not request.called - def test_validate_metadata_server_url_if_any(self): - aws.Credentials.validate_metadata_server_url_if_any( - "http://[fd00:ec2::254]/latest/meta-data/placement/availability-zone", "url" - ) - aws.Credentials.validate_metadata_server_url_if_any( - "http://169.254.169.254/latest/meta-data/placement/availability-zone", "url" - ) - - with pytest.raises(ValueError) as excinfo: - aws.Credentials.validate_metadata_server_url_if_any( - "http://fd00:ec2::254/latest/meta-data/placement/availability-zone", - "url", - ) - assert excinfo.match("Invalid hostname 'fd00' for 'url'") - - with pytest.raises(ValueError) as excinfo: - aws.Credentials.validate_metadata_server_url_if_any( - "http://abc.com/latest/meta-data/placement/availability-zone", "url" - ) - assert excinfo.match("Invalid hostname 'abc.com' for 'url'") - - def test_retrieve_subject_token_invalid_hosts(self): - keys = ["url", "region_url", "imdsv2_session_token_url"] - for key in keys: - credential_source = self.CREDENTIAL_SOURCE.copy() - credential_source[ - key - ] = "http://abc.com/latest/meta-data/iam/security-credentials" - - with pytest.raises(ValueError) as excinfo: - self.make_credentials(credential_source=credential_source) - assert excinfo.match("Invalid hostname 'abc.com' for '{}'".format(key)) - @mock.patch("google.auth._helpers.utcnow") def test_retrieve_subject_token_success_ipv6(self, utcnow): utcnow.return_value = datetime.datetime.strptime(