diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ccadc42b..6b7abb366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ [1]: https://pypi.org/project/google-auth/#history +### [1.33.1](https://www.github.com/googleapis/google-auth-library-python/compare/v1.33.0...v1.33.1) (2021-07-20) + + +### Bug Fixes + +* fallback to source creds expiration in downscoped tokens ([#805](https://www.github.com/googleapis/google-auth-library-python/issues/805)) ([dfad661](https://www.github.com/googleapis/google-auth-library-python/commit/dfad66128c6ee7513e5565d39bc7b002055dd0d5)) + + +### Reverts + +* revert "feat: service account is able to use a private token endpoint ([#784](https://www.github.com/googleapis/google-auth-library-python/issues/784))" ([#808](https://www.github.com/googleapis/google-auth-library-python/issues/808)) ([d94e65c](https://www.github.com/googleapis/google-auth-library-python/commit/d94e65c0e441183403608d762b92b30b77e21eeb)) + ## [1.33.0](https://www.github.com/googleapis/google-auth-library-python/compare/v1.32.1...v1.33.0) (2021-07-14) diff --git a/google/auth/downscoped.py b/google/auth/downscoped.py index 800f2894c..96a4e6547 100644 --- a/google/auth/downscoped.py +++ b/google/auth/downscoped.py @@ -479,8 +479,16 @@ def refresh(self, request): additional_options=self._credential_access_boundary.to_json(), ) self.token = response_data.get("access_token") - lifetime = datetime.timedelta(seconds=response_data.get("expires_in")) - self.expiry = now + lifetime + # For downscoping CAB flow, the STS endpoint may not return the expiration + # field for some flows. The generated downscoped token should always have + # the same expiration time as the source credentials. When no expires_in + # field is returned in the response, we can just get the expiration time + # from the source credentials. + if response_data.get("expires_in"): + lifetime = datetime.timedelta(seconds=response_data.get("expires_in")) + self.expiry = now + lifetime + else: + self.expiry = self._source_credentials.expiry @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject) def with_quota_project(self, quota_project_id): diff --git a/google/auth/version.py b/google/auth/version.py index e74f1e70f..6327f8588 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__ = "1.33.0" +__version__ = "1.33.1" diff --git a/google/oauth2/service_account.py b/google/oauth2/service_account.py index 8f18f26ea..dd3658994 100644 --- a/google/oauth2/service_account.py +++ b/google/oauth2/service_account.py @@ -80,7 +80,6 @@ from google.oauth2 import _client _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds -_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" class Credentials( @@ -383,7 +382,7 @@ def _make_authorization_grant_assertion(self): # The issuer must be the service account email. "iss": self._service_account_email, # The audience must be the auth token endpoint's URI - "aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT, + "aud": self._token_uri, "scope": _helpers.scopes_to_string(self._scopes or ()), } @@ -644,7 +643,7 @@ def _make_authorization_grant_assertion(self): # The issuer must be the service account email. "iss": self.service_account_email, # The audience must be the auth token endpoint's URI - "aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT, + "aud": self._token_uri, # The target audience specifies which service the ID token is # intended for. "target_audience": self._target_audience, diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index e416918d4..46b521c76 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/tests/oauth2/test_service_account.py b/tests/oauth2/test_service_account.py index 370438f48..5852d3714 100644 --- a/tests/oauth2/test_service_account.py +++ b/tests/oauth2/test_service_account.py @@ -167,7 +167,7 @@ def test__make_authorization_grant_assertion(self): token = credentials._make_authorization_grant_assertion() payload = jwt.decode(token, PUBLIC_CERT_BYTES) assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL - assert payload["aud"] == service_account._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert payload["aud"] == self.TOKEN_URI def test__make_authorization_grant_assertion_scoped(self): credentials = self.make_credentials() @@ -440,7 +440,7 @@ def test__make_authorization_grant_assertion(self): token = credentials._make_authorization_grant_assertion() payload = jwt.decode(token, PUBLIC_CERT_BYTES) assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL - assert payload["aud"] == service_account._GOOGLE_OAUTH2_TOKEN_ENDPOINT + assert payload["aud"] == self.TOKEN_URI assert payload["target_audience"] == self.TARGET_AUDIENCE @mock.patch("google.oauth2._client.id_token_jwt_grant", autospec=True) diff --git a/tests/test_downscoped.py b/tests/test_downscoped.py index ac60e5b00..795ec2942 100644 --- a/tests/test_downscoped.py +++ b/tests/test_downscoped.py @@ -80,10 +80,11 @@ class SourceCredentials(credentials.Credentials): - def __init__(self, raise_error=False): + def __init__(self, raise_error=False, expires_in=3600): super(SourceCredentials, self).__init__() self._counter = 0 self._raise_error = raise_error + self._expires_in = expires_in def refresh(self, request): if self._raise_error: @@ -93,7 +94,7 @@ def refresh(self, request): now = _helpers.utcnow() self._counter += 1 self.token = "ACCESS_TOKEN_{}".format(self._counter) - self.expiry = now + datetime.timedelta(seconds=3600) + self.expiry = now + datetime.timedelta(seconds=self._expires_in) def make_availability_condition(expression, title=None, description=None): @@ -539,6 +540,47 @@ def test_refresh(self, unused_utcnow): # Confirm source credentials called with the same request instance. wrapped_souce_cred_refresh.assert_called_with(request) + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) + def test_refresh_without_response_expires_in(self, unused_utcnow): + response = SUCCESS_RESPONSE.copy() + # Simulate the response is missing the expires_in field. + # The downscoped token expiration should match the source credentials + # expiration. + del response["expires_in"] + expected_expires_in = 1800 + # Simulate the source credentials generates a token with 1800 second + # expiration time. The generated downscoped token should have the same + # expiration time. + source_credentials = SourceCredentials(expires_in=expected_expires_in) + expected_expiry = datetime.datetime.min + datetime.timedelta( + seconds=expected_expires_in + ) + headers = {"Content-Type": "application/x-www-form-urlencoded"} + request_data = { + "grant_type": GRANT_TYPE, + "subject_token": "ACCESS_TOKEN_1", + "subject_token_type": SUBJECT_TOKEN_TYPE, + "requested_token_type": REQUESTED_TOKEN_TYPE, + "options": urllib.parse.quote(json.dumps(CREDENTIAL_ACCESS_BOUNDARY_JSON)), + } + request = self.make_mock_request(status=http_client.OK, data=response) + credentials = self.make_credentials(source_credentials=source_credentials) + + # Spy on calls to source credentials refresh to confirm the expected request + # instance is used. + with mock.patch.object( + source_credentials, "refresh", wraps=source_credentials.refresh + ) as wrapped_souce_cred_refresh: + credentials.refresh(request) + + self.assert_request_kwargs(request.call_args[1], headers, request_data) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == response["access_token"] + # Confirm source credentials called with the same request instance. + wrapped_souce_cred_refresh.assert_called_with(request) + def test_refresh_token_exchange_error(self): request = self.make_mock_request( status=http_client.BAD_REQUEST, data=ERROR_RESPONSE diff --git a/tests_async/oauth2/test_service_account_async.py b/tests_async/oauth2/test_service_account_async.py index 3dce13d82..40794536c 100644 --- a/tests_async/oauth2/test_service_account_async.py +++ b/tests_async/oauth2/test_service_account_async.py @@ -152,10 +152,7 @@ def test__make_authorization_grant_assertion(self): token = credentials._make_authorization_grant_assertion() payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL - assert ( - payload["aud"] - == service_account.service_account._GOOGLE_OAUTH2_TOKEN_ENDPOINT - ) + assert payload["aud"] == self.TOKEN_URI def test__make_authorization_grant_assertion_scoped(self): credentials = self.make_credentials() @@ -314,10 +311,7 @@ def test__make_authorization_grant_assertion(self): token = credentials._make_authorization_grant_assertion() payload = jwt.decode(token, test_service_account.PUBLIC_CERT_BYTES) assert payload["iss"] == self.SERVICE_ACCOUNT_EMAIL - assert ( - payload["aud"] - == service_account.service_account._GOOGLE_OAUTH2_TOKEN_ENDPOINT - ) + assert payload["aud"] == self.TOKEN_URI assert payload["target_audience"] == self.TARGET_AUDIENCE @mock.patch("google.oauth2._client_async.id_token_jwt_grant", autospec=True)